From 5db02adbe92ab9a1741a29058ce85bcb6e1b2d66 Mon Sep 17 00:00:00 2001 From: Joe Tretter Date: Tue, 19 May 2026 15:52:22 -0500 Subject: [PATCH] Fix upstream fixture-backed level setups --- LevelsCompare.md | 20 ++-- README.md | 3 +- app/build.gradle.kts | 4 +- .../tretter/githugandroid/GitRuntime.kt | 14 ++- .../githugandroid/levels/BlameLevel.kt | 93 +++++++++++++++---- .../githugandroid/levels/CherryPickLevel.kt | 50 ++++++++-- .../githugandroid/levels/ConflictLevel.kt | 75 ++++++++++++++- .../githugandroid/levels/DeleteBranchLevel.kt | 16 +++- .../tretter/githugandroid/levels/DiffLevel.kt | 85 ++++++++++------- .../levels/FindOldBranchLevel.kt | 31 ++++++- .../tretter/githugandroid/levels/GrepLevel.kt | 88 +++++++++++++++++- .../githugandroid/levels/StashLevel.kt | 32 ++++--- .../githugandroid/LevelSolutionsTest.kt | 46 +++++++++ 13 files changed, 462 insertions(+), 95 deletions(-) diff --git a/LevelsCompare.md b/LevelsCompare.md index 224d25e..37a7135 100644 --- a/LevelsCompare.md +++ b/LevelsCompare.md @@ -22,7 +22,7 @@ This file records the upstream Ruby setup and validation intent beside the Andro | `number_of_files_committed` | Initializes repo with committed modified files plus staged/untracked files, leaving exactly two files staged for commit. | Initializes equivalent file states with two staged files. | Equivalent. | | `rm` | Initializes repo, commits `deleteme.rb`, then deletes it from the working tree. | Initializes repo with tracked deleted `deleteme.rb`. | Equivalent. | | `rm_cached` | Initializes repo, stages `.gitignore` and `deleteme.rb`. | Initializes repo with both files staged. | Equivalent. | -| `stash` | Copies upstream fixture containing committed `lyrics.txt` plus local modifications. | Initializes repo with tracked modified `lyrics.txt` and matching native fixture. | Equivalent. | +| `stash` | Copies upstream fixture containing committed `lyrics.txt` plus local modifications. | Native setup creates the same committed lyrics text and the same trailing `Hey!` working-tree modification. | Equivalent. | | `rename` | Initializes repo, commits `oldfile.txt`. | Initializes repo with tracked `oldfile.txt` and commit. | Equivalent. | | `restructure` | Initializes repo with tracked `about.html`, `contact.html`, `index.html`. | Same tracked HTML files. | Equivalent. | | `log` | Initializes repo with one commit whose message identifies the target. | Same one modeled commit. | Equivalent. | @@ -38,32 +38,32 @@ This file records the upstream Ruby setup and validation intent beside the Andro | `pull` | Initializes repo and adds remote URL `https://github.com/pull-this/thing-to-pull`. | Native setup creates local synthetic remote with a remote commit. | Deliberate offline equivalent to upstream network remote. | | `remote_add` | Initializes repo with no origin remote. | Same. | Equivalent. | | `push` | Creates divergent local and remote master histories: local has `file3`, remote has `file4`. | Native setup creates synthetic divergent origin with the same file/commit shape. | Equivalent. | -| `diff` | Copies upstream fixture with committed `app.rb` and one changed line. | Native setup creates committed then modified `app.rb`. | Equivalent. | -| `blame` | Copies upstream fixture where password line is authored by Spider Man. | Native setup creates deterministic author history for `config.rb`. | Equivalent. | +| `diff` | Copies upstream fixture with committed `app.rb` and one changed line. | Native setup creates the same committed `app.rb` and the same line 26 working-tree modification. | Equivalent. | +| `blame` | Copies upstream fixture where password line is authored by Spider Man. | Native setup creates the same `config.rb` content with the password line authored by Spider Man. | Equivalent. | | `branch` | Initializes repo with one `README` commit. | Same. | Equivalent. | | `checkout` | Initializes repo with one `README` commit. | Same. | Equivalent. | | `checkout_tag` | Creates five commits and tags `v1.0`, `v1.2`, `v1.5`. | Same commit/tag shape. | Equivalent. | | `checkout_tag_over_branch` | Creates same tags and also a branch named `v1.2`. | Same tag-plus-branch ambiguity. | Equivalent. | | `branch_at` | Creates three commits on `file1`. | Same three modeled commits and native setup. | Equivalent. | -| `delete_branch` | Copies fixture with `delete_me` branch. | Initializes repo with `delete_me` branch. | Equivalent. | +| `delete_branch` | Copies fixture with committed `readme` and `delete_me` branch at the same commit as `master`. | Native setup creates committed `readme` with `master` and `delete_me` pointing at it. | Equivalent. | | `push_branch` | Creates local and remote repos with multiple branches and target `test_branch`. | Native setup creates synthetic remote and same branch intent. | Equivalent state projection. | | `merge` | Copies fixture with `file1` on master and `file2` on feature. | Native setup creates master `file1` and feature `file2`. | Equivalent. | | `fetch` | Creates local master tracking origin/master; remote later gains `new_branch` with `file1`. | Native setup creates the same synthetic remote branch scenario; model starts with one fetched head. | Equivalent after latest update. | | `rebase` | Copies fixture with divergent `master` and `feature`. | Native setup creates matching divergent `master` and `feature`. | Equivalent. | | `rebase_onto` | Creates `authors.md`, bad `wrong_branch`, and `readme-update` branch needing `--onto`. | Native setup creates the same branch graph and files. | Equivalent setup. | | `repack` | Initializes repo with one committed `foo`. | Same. | Equivalent. | -| `cherry-pick` | Copies fixture with `master` and `new-feature` commits. | Native setup creates equivalent branches/files. | Equivalent. | -| `grep` | Copies fixture with four TODO entries. | Initializes `app.rb` with four TODO entries. | Equivalent. | +| `cherry-pick` | Copies fixture with `master` and `new-feature`; the desired README commit is not the branch tip. | Native setup creates the same branch shape: learner must cherry-pick the earlier README commit from `new-feature`. | Equivalent. | +| `grep` | Copies fixture with four TODO entries across `app.rb` and `config.rb`. | Native setup creates both source files with the same four TODO entries. | Equivalent. | | `rename_commit` | Creates commits `Initial commit`, `First coommit`, `Second commit`. | Same typo commit sequence. | Equivalent. | | `squash` | Creates initial hidden commit plus README and three squash-target README updates. | Same commit sequence. | Equivalent. | | `merge_squash` | Creates master and `long-feature-branch` with multiple feature changes. | Native setup creates the same branch and `file3` feature effect. | Mostly equivalent; Android currently validates less detail. | | `reorder` | Creates commits `Initial Setup`, `First commit`, `Third commit`, `Second commit`. | Same order. | Equivalent. | | `bisect` | Copies upstream Ruby fixture where `ruby prog.rb 5` or `make test` identifies bad hash `18ed2ac`. | Creates a native Git history with `balance.txt`, `test-balance.sh`, and `known-good` tag. | Intentional Android adaptation: no Ruby or make dependency, but real `git bisect` remains playable. | | `stage_lines` | Commits initial `feature.rb`, then leaves two unstaged feature lines. | Initializes tracked `feature.rb` containing both feature lines. | Setup is close, but Android does not yet model partial staged vs unstaged hunks. | -| `find_old_branch` | Copies fixture with recoverable `solve_world_hunger` branch. | Initializes branch map with `solve_world_hunger`. | Equivalent for visible exercise state. | +| `find_old_branch` | Copies fixture whose active distractor branch is renamed to `master`, with other distractor branches and recoverable `solve_world_hunger`. | Native setup creates `master`, `solve_world_hunger`, `cure_common_cold`, and `blowup_sun_for_ransom` branches with matching working-tree files. | Equivalent. | | `revert` | Creates commits `First commit`, `Bad commit`, `Second commit`. | Same commit messages. | Equivalent. | | `restore` | Creates `file1`, `file2`, then creates and removes `file3` so it is recoverable from reflog/history. | Native setup creates matching history and removes `file3`; model starts without `file3`. | Equivalent. | -| `conflict` | Copies fixture with `master` and `mybranch` conflict in `poem.txt`. | Initializes equivalent branch/file conflict fixture. | Equivalent setup. | +| `conflict` | Copies fixture with `master` and `mybranch` conflict in non-empty `poem.txt`. | Native setup creates the conflicting poem history, leaving `master` with `Categorized shoes by color` and `mybranch` with the correct `Sat on a wall` line. | Equivalent setup. | | `submodule` | Initializes empty repo. | Same. | Equivalent; network submodule operation is modeled. | | `contribute` | No local setup; solution clones upstream externally. | Empty state. | Deliberate simplification for mobile/offline final prompt. | @@ -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. | `poem.txt` contains expected merged poem text. | Known gap: Android does not model merge-parent count for conflict resolution. | +| `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. | | `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,6 @@ 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`: model merge parent count or inspect merge commit parents. +- `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 3b1d55d..aec7e5c 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,8 @@ The Android port keeps the upstream GitHug level order, but some upstream fixtur | `pull`, `fetch`, `push`, `push_branch`, `push_tags` | Use remote-style workflows from upstream fixtures. | Use local synthetic remotes created inside the sandbox and validate fetched/pushed refs through `RepoState`. | This preserves Git behavior without external network dependencies. | | `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`, `conflict`, `repack` | Upstream validates detailed object graph, file content, 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. | +| `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. | ## Level Authoring diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f2c66a3..1914a33 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 = 161 - versionName = "0.1.160" + versionCode = 162 + versionName = "0.1.161" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt index 26f3ed2..d6432b4 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt @@ -359,7 +359,7 @@ class GitRepositoryRuntime private constructor( val shouldUseSandboxSemantics = when (gitCommand) { "add" -> tokens.any { it == "-p" || it == "--patch" || it == "-i" || it == "--interactive" } "rebase" -> "--onto" in tokens - "merge" -> "--squash" in tokens || tokens.lastOrNull() == "mybranch" || tokens.lastOrNull() == "feature" + "merge" -> "--squash" in tokens || tokens.lastOrNull() == "feature" "revert", "stash" -> true "checkout" -> tokens.any { it == "file3" } || tokens.takeLast(2) == listOf("--", "config.rb") "submodule" -> tokens.getOrNull(2) == "add" @@ -553,7 +553,17 @@ class GitRepositoryRuntime private constructor( tokens: List, outputLines: List = emptyList(), ): RepoState { - if (tokens.firstOrNull() != "git") return inspectedRepo + if (tokens.firstOrNull() != "git") { + return inspectedRepo.copy( + stashes = mergeDistinct(previousRepo.stashes, inspectedRepo.stashes), + fetchedBranches = previousRepo.fetchedBranches + inspectedRepo.fetchedBranches, + fetchHeadCount = inspectedRepo.fetchHeadCount, + pushedBranches = previousRepo.pushedBranches + inspectedRepo.pushedBranches, + pushedTags = previousRepo.pushedTags + inspectedRepo.pushedTags, + submodules = previousRepo.submodules + inspectedRepo.submodules, + maintenanceActions = previousRepo.maintenanceActions + inspectedRepo.maintenanceActions, + ) + } return when (tokens.getOrNull(1)) { "bisect" -> { 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 b319368..f74f01e 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/BlameLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/BlameLevel.kt @@ -18,11 +18,13 @@ internal fun blameLevel(): Level = level( initialized = true, files = listOf(GitFile("config.rb", blameLevelFinalConfigRb(), tracked = true)), commits = listOf( - CommitNode("0000001", "Add default config"), - CommitNode("0000002", "Add secret config"), - CommitNode("0000003", "Document timeout"), + CommitNode("0000001", "added more options (no really)"), + CommitNode("0000002", "added more options"), + CommitNode("0000003", "added password"), + CommitNode("0000004", "added options"), + CommitNode("0000005", "added config with name"), ), - branches = mapOf("master" to 3), + branches = mapOf("master" to 5), ) }, nativeSetup = { @@ -30,26 +32,67 @@ internal fun blameLevel(): Level = level( write( "config.rb", """ - Githug::Application.configure do - config.cache_classes = true - config.log_level = :info + class Config + def initialize(name) + @name = name + end end """.trimIndent() + "\n", ) - addCommit("Add default config", "config.rb", author = "Peter Parker ") + addCommit("added config with name", "config.rb", author = "Gary Rennie ") write( "config.rb", """ - Githug::Application.configure do - config.cache_classes = true - config.log_level = :info - config.password = "correct horse battery staple" + class Config + def initialize(name, options = {}) + @name = name + + if options[:downcase] + @name.downcase! + end + + end end """.trimIndent() + "\n", ) - addCommit("Add secret config", "config.rb", author = "Spider Man ") - append("config.rb", "config.timeout = 30\n") - addCommit("Document timeout", "config.rb", author = "Mary Jane ") + addCommit("added options", "config.rb", author = "Spider Man ") + write( + "config.rb", + """ + class Config + attr_accessor :name, :password + def initialize(name, password = nil, options = {}) + @name = name + + if options[:downcase] + @name.downcase! + end + + end + end + """.trimIndent() + "\n", + ) + addCommit("added password", "config.rb", author = "Bruce Banner ") + write( + "config.rb", + """ + class Config + attr_accessor :name, :password + def initialize(name, password = nil, options = {}) + @name = name + @password = password || "i<3evil" + + if options[:downcase] + @name.downcase! + end + + end + end + """.trimIndent() + "\n", + ) + addCommit("added more options", "config.rb", author = "Spider Man ") + write("config.rb", blameLevelFinalConfigRb()) + addCommit("added more options (no really)", "config.rb", author = "Gary Rennie ") true }, validator = commandAnswer("Spider Man"), @@ -60,10 +103,20 @@ internal fun blameLevel(): Level = level( private fun blameLevelFinalConfigRb(): String = """ - Githug::Application.configure do - config.cache_classes = true - config.log_level = :info - config.password = "correct horse battery staple" + class Config + attr_accessor :name, :password + def initialize(name, password = nil, options = {}) + @name = name + @password = password || "i<3evil" + + if options[:downcase] + @name.downcase! + end + + if options[:upcase] + @name.upcase! + end + + end end - config.timeout = 30 """.trimIndent() + "\n" 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 b84ca85..5005d18 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/CherryPickLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/CherryPickLevel.kt @@ -12,8 +12,24 @@ internal fun cherryPickLevel(): Level = level( title = "Cherry Pick", description = "Your new feature isn't worth the time and you're going to delete it. But it has one commit that fills in `README` file, and you want this commit to be on the master as well.", hints = listOf("Sneak a peek at the `git help cherry-pick` command."), - commandSuggestions = listOf("git log --oneline new-feature", "git cherry-pick new-feature"), - setup = { RepoState(initialized = true, files = listOf(GitFile("nokia.js", tracked = true)), commits = listOf(CommitNode("1", "Added fancy branded output"), CommitNode("2", "Filled in README.md with proper input")), branches = mapOf("master" to 1, "new-feature" to 2)) }, + commandSuggestions = listOf("git log --oneline new-feature", "git cherry-pick "), + setup = { + RepoState( + initialized = true, + files = listOf( + GitFile("README.md", "I'll fill in the file some time later..\n", tracked = true), + GitFile("hardcore-math.js", cherryPickLevelHardcoreMathJs(), tracked = true), + GitFile("nokia.js", "console.log(\"[NOKIA] Connecting people\");\n", tracked = true), + ), + commits = listOf( + CommitNode("0000001", "Added fancy branded output"), + CommitNode("0000002", "Renamed project.js -> herdcore-math.js"), + CommitNode("0000003", "Added a hardcore math module"), + CommitNode("0000004", "Initial commit"), + ), + branches = mapOf("master" to 4, "new-feature" to 5), + ) + }, validator = repoPredicate { repo -> repo.files.any { it.name == "README.md" && it.tracked } && repo.headBranch == "master" && @@ -21,17 +37,35 @@ internal fun cherryPickLevel(): Level = level( }, nativeSetup = { resetFiles() - write("nokia.js", "console.log('Nokia tune')\n") + write("README.md", "I'll fill in the file some time later..\n") + addCommit("Initial commit", "README.md") + write("project.js", "for(var i = 0; i < 10; i++) {\n console.log(42 * i);\n}\n") + addCommit("Added a hardcore math module", "project.js") + git("mv", "project.js", "hardcore-math.js") + commit("Renamed project.js -> herdcore-math.js") + write("nokia.js", "console.log(\"[NOKIA] Connecting people\");\n") addCommit("Added fancy branded output", "nokia.js") - checkoutNew("new-feature") - write("README.md", "Proper input instructions\n") + git("branch", "new-feature", "HEAD~3") + checkout("new-feature") + write("feature.js", "function connect() {\n}\n") + addCommit("Added a stub for the feature", "feature.js") + write("README.md", "This project now documents its proper input.\n") addCommit("Filled in README.md with proper input", "README.md") + append("feature.js", "connect();\n") + addCommit("Fixed feature", "feature.js") + append("feature.js", "console.log('done');\n") + addCommit("some small fixes", "feature.js") checkout("master") - git("branch", "-f", "feature", "new-feature") true }, testCases = listOf( - levelTestCase("cherry pick new feature tip", "git cherry-pick new-feature"), - levelTestCase("cherry pick feature alias", "git cherry-pick feature"), + levelTestCase("cherry pick README commit", "git cherry-pick new-feature~2"), ), ) + +private fun cherryPickLevelHardcoreMathJs(): String = + """ + for(var i = 0; i < 10; i++) { + console.log(42 * i); + } + """.trimIndent() + "\n" 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 d3b0392..734dbb5 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/ConflictLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/ConflictLevel.kt @@ -13,9 +13,78 @@ internal fun conflictLevel(): Level = level( description = "You need to merge mybranch into the current branch (master). But there may be some incorrect changes in mybranch which may cause conflicts. Solve any merge-conflicts you come across and finish the merge.", hints = emptyList(), commandSuggestions = listOf("git merge mybranch"), - setup = { RepoState(initialized = true, files = listOf(GitFile("poem.txt", tracked = true)), branches = mapOf("master" to 2, "mybranch" to 2)) }, - validator = repoPredicate { repo -> repo.files.find { it.name == "poem.txt" }?.content?.contains("Humpty Dumpty") == true }, + setup = { + RepoState( + initialized = true, + files = listOf(GitFile("poem.txt", conflictLevelMasterPoem(), tracked = true)), + branches = mapOf("master" to 3, "mybranch" to 4), + ) + }, + nativeSetup = { + resetFiles() + write("poem.txt", conflictLevelInitialPoem()) + addCommit("Initial commit", "poem.txt") + checkoutNew("mybranch") + write("poem.txt", conflictLevelBranchWrongPoem()) + addCommit("Added lines", "poem.txt") + append("poem.txt", "\nThis is a cool poem everyone should learn.\n") + addCommit("Added comment", "poem.txt") + write("poem.txt", conflictLevelSolvedPoem()) + addCommit("Changed the poem", "poem.txt") + checkout("master") + write("poem.txt", conflictLevelSolvedPoem()) + addCommit("Added two lines", "poem.txt") + write("poem.txt", conflictLevelMasterPoem()) + addCommit("Updated the poem", "poem.txt") + true + }, + validator = repoPredicate { repo -> + val poem = repo.files.find { it.name == "poem.txt" }?.content.orEmpty() + repo.headBranch == "master" && + "merge" in repo.maintenanceActions && + "Sat on a wall" in poem && + poem.none { it in "<>=|" } + }, testCases = listOf( - levelTestCase("merge and resolve cleanly", "git merge mybranch"), + levelTestCase( + "merge and resolve cleanly", + "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 add poem.txt", + "git commit --no-edit", + ), ), ) + +private fun conflictLevelInitialPoem(): String = + """ + Humpty dumpty + Had a great fall + """.trimIndent() + "\n" + +private fun conflictLevelBranchWrongPoem(): String = + """ + Humpty dumpty + Fell on a doll + Humpty dumpty + Had a great fall + """.trimIndent() + "\n" + +private fun conflictLevelSolvedPoem(): String = + """ + Humpty dumpty + Sat on a wall + Humpty dumpty + Had a great fall + """.trimIndent() + "\n" + +private fun conflictLevelMasterPoem(): String = + """ + Humpty dumpty + Categorized shoes by color + Humpty dumpty + Had a great fall + """.trimIndent() + "\n" 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 90b0f23..b87b561 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/DeleteBranchLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/DeleteBranchLevel.kt @@ -13,7 +13,21 @@ internal fun deleteBranchLevel(): Level = level( description = "You have created too many branches for your project. There is an old branch in your repo called 'delete_me', you should delete it.", hints = listOf("Running 'git --help branch' will give you a list of branch commands."), commandSuggestions = listOf("git branch -d delete_me"), - setup = { RepoState(initialized = true, branches = mapOf("master" to 1, "delete_me" to 1)) }, + setup = { + RepoState( + initialized = true, + files = listOf(GitFile("readme", tracked = true)), + commits = listOf(CommitNode("0000001", "first commit")), + branches = mapOf("master" to 1, "delete_me" to 1), + ) + }, + nativeSetup = { + resetFiles() + write("readme") + addCommit("first commit", "readme") + git("branch", "delete_me") + true + }, validator = repoPredicate { repo -> "delete_me" !in repo.branches }, testCases = listOf( levelTestCase("delete branch", "git branch -d 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 22ae080..6c27bf8 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/DiffLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/DiffLevel.kt @@ -17,14 +17,14 @@ internal fun diffLevel(): Level = level( RepoState( initialized = true, files = listOf(GitFile("app.rb", diffLevelModifiedAppRb(), tracked = true)), - commits = listOf(CommitNode("0000001", "Add app routes")), + commits = listOf(CommitNode("0000001", "added app.rb")), branches = mapOf("master" to 1), ) }, nativeSetup = { resetFiles() write("app.rb", diffLevelBaselineAppRb()) - addCommit("Add app routes", "app.rb") + addCommit("added app.rb", "app.rb") write("app.rb", diffLevelModifiedAppRb()) true }, @@ -34,38 +34,53 @@ internal fun diffLevel(): Level = level( ), ) -internal fun diffLevelBaselineAppRb(): String = buildString { - appendLine("require 'sinatra'") - appendLine("require 'json'") - appendLine() - appendLine("helpers do") - appendLine(" def get_response(source)") - appendLine(" JSON.parse(File.read(source))['message']") - appendLine(" end") - appendLine("end") - appendLine() - appendLine("get '/' do") - appendLine(" @message = 'hello'") - appendLine(" erb :index") - appendLine("end") - appendLine() - appendLine("get '/page' do") - appendLine(" @message = 'page'") - appendLine(" erb :page") - appendLine("end") - appendLine() - appendLine("get '/yet_another' do") - appendLine(" @message = 'another'") - appendLine(" erb :success") - appendLine("end") - appendLine() - appendLine("get '/another_page' do") - appendLine(" @message = get_response('data.json')") - appendLine(" erb :another") - appendLine("end") - appendLine() - appendLine("# end of application") -} +internal fun diffLevelBaselineAppRb(): String = + """ + require 'sinatra' + require 'oauth2' + require 'json' + enable :sessions + + def client + OAuth2::Client.new("mTeZFqkCmzc8JnjKXaSww95bFFxhUpp1wwmSi8vG", "a9OMyEdW7JvWThHmmvFcShR9P2dyad3EGuA2ULDh", :site => "http://localhost:3000") + end + + get "/auth/test" do + redirect client.auth_code.authorize_url(:redirect_uri => redirect_uri) + end + + get '/auth/test/callback' do + access_token = client.auth_code.get_token(params[:code], :redirect_uri => redirect_uri) + session[:access_token] = access_token.token + @message = "Successfully authenticated with the server" + erb :success + end + + get '/yet_another' do + @message = get_response('data.json') + erb :success + end + get '/another_page' do + @message = get_response('data.json') + erb :another + end + + def get_response(url) + access_token = OAuth2::AccessToken.new(client, session[:access_token]) + JSON.parse(access_token.get("/api/v1/#{url}").body) + end + + + def redirect_uri + uri = URI.parse(request.url) + uri.path = '/auth/test/callback' + uri.query = nil + uri.to_s + end + """.trimIndent() + "\n" internal fun diffLevelModifiedAppRb(): String = - diffLevelBaselineAppRb().replace("get_response('data.json')", "get_response('server.json')") + diffLevelBaselineAppRb().replace( + "get '/another_page' do\n @message = get_response('data.json')", + "get '/another_page' do\n @message = get_response('server.json')", + ) 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 91aba22..1ba18d7 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/FindOldBranchLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/FindOldBranchLevel.kt @@ -13,7 +13,36 @@ internal fun findOldBranchLevel(): Level = level( description = "You have been working on a branch but got distracted by a major issue. Switch back to that branch even though you forgot the name of it.", hints = listOf("Ever played with the `git reflog` command?"), commandSuggestions = listOf("git checkout solve_world_hunger"), - setup = { RepoState(initialized = true, branches = mapOf("master" to 1, "solve_world_hunger" to 2)) }, + setup = { + RepoState( + initialized = true, + headBranch = "master", + files = listOf( + GitFile("myfile.txt", "THIS TEXT DOESN'T MATTER\n", tracked = true), + GitFile("TODO", "FIND THE JOKER\n", tracked = true), + ), + branches = mapOf( + "blowup_sun_for_ransom" to 1, + "cure_common_cold" to 1, + "master" to 2, + "solve_world_hunger" to 2, + ), + ) + }, + nativeSetup = { + resetFiles() + write("myfile.txt", "THIS TEXT DOESN'T MATTER\n") + addCommit("initial commit", "myfile.txt") + git("branch", "blowup_sun_for_ransom") + git("branch", "cure_common_cold") + checkoutNew("solve_world_hunger") + write("TODO", "FIX WORLD HUNGER\n") + addCommit("commit todo", "TODO") + checkout("master") + write("TODO", "FIND THE JOKER\n") + addCommit("commit another todo", "TODO") + true + }, validator = repoPredicate { repo -> repo.headBranch == "solve_world_hunger" }, testCases = listOf( levelTestCase("checkout old branch", "git checkout 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 ca1a1a0..e7221e1 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/GrepLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/GrepLevel.kt @@ -13,9 +13,95 @@ internal fun grepLevel(): Level = level( description = "Your project's deadline approaches, you should evaluate how many TODOs are left in your code", hints = listOf("You want to research the `git grep` command."), commandSuggestions = listOf("git grep TODO"), - setup = { RepoState(initialized = true, files = listOf(GitFile("app.rb", "# TODO\n# TODO\n# TODO\n# TODO", tracked = true)), branches = mapOf("master" to 1)) }, + setup = { + RepoState( + initialized = true, + files = listOf( + GitFile("app.rb", grepLevelAppRb(), tracked = true), + GitFile("config.rb", grepLevelConfigRb(), tracked = true), + ), + commits = listOf(CommitNode("0000001", "Add application files.")), + branches = mapOf("master" to 1), + ) + }, + nativeSetup = { + resetFiles() + write("app.rb", grepLevelAppRb()) + write("config.rb", grepLevelConfigRb()) + addCommit("Add application files.", "app.rb", "config.rb") + true + }, validator = commandAnswer("4"), testCases = listOf( levelTestCase("answer todo count", "4"), ), ) + +private fun grepLevelAppRb(): String = + """ + require 'sinatra' + require 'oauth2' + require 'json' + enable :sessions + + # TODO Make site url variable. + def client + OAuth2::Client.new("mTeZFqkCmzc8JnjKXaSww95bFFxhUpp1wwmSi8vG", "a9OMyEdW7JvWThHmmvFcShR9P2dyad3EGuA2ULDh", :site => "http://localhost:3000") + end + + get "/auth/test" do + redirect client.auth_code.authorize_url(:redirect_uri => redirect_uri) + end + + get '/auth/test/callback' do + access_token = client.auth_code.get_token(params[:code], :redirect_uri => redirect_uri) + session[:access_token] = access_token.token + @message = "Successfully authenticated with the server" + erb :success + end + + get '/yet_another' do + @message = get_response('data.json') + erb :success + end + + get '/another_page' do + @message = get_response('server.json') + erb :another + end + + # TODO Make API version variable. + def get_response(url) + access_token = OAuth2::AccessToken.new(client, session[:access_token]) + JSON.parse(access_token.get("/api/v1/#{url}").body) + end + + # TODO Redirecting queries could be useful. + def redirect_uri + uri = URI.parse(request.url) + uri.path = '/auth/test/callback' + uri.query = nil + uri.to_s + end + """.trimIndent() + "\n" + +private fun grepLevelConfigRb(): String = + """ + class Config + attr_accessor :name, :password + def initialize(name, password = nil, options = {}) + @name = name + # TODO Move password to a configuration file. + @password = password || "i<3evil" + + if options[:downcase] + @name.downcase! + end + + if options[:upcase] + @name.upcase! + end + + end + end + """.trimIndent() + "\n" 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 f1e7a6f..f900b3e 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/StashLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/StashLevel.kt @@ -13,20 +13,12 @@ internal fun stashLevel(): Level = level( description = "You've made some changes and want to work on them later. You should save them, but don't commit them.", hints = listOf("It's like stashing. Try finding an appropriate git command."), commandSuggestions = listOf("git stash", "git status"), - setup = { RepoState(initialized = true, files = listOf(GitFile("lyrics.txt", "modified lyrics", tracked = true)), commits = listOf(CommitNode("0000001", "Initial commit")), branches = mapOf("master" to 1)) }, + setup = { RepoState(initialized = true, files = listOf(GitFile("lyrics.txt", stashLevelModifiedLyrics(), tracked = true)), commits = listOf(CommitNode("0000001", "Add some lyrics")), branches = mapOf("master" to 1)) }, nativeSetup = { resetFiles() - write( - "lyrics.txt", - """ - Down in Louisiana in that sunny clime, - They play a class of music that is super fine, - And it makes no difference if its rain or shine, - You can hear that that jazz band music playing all the time. - """.trimIndent() + "\n", - ) + write("lyrics.txt", stashLevelCommittedLyrics()) addCommit("Add some lyrics", "lyrics.txt") - append("lyrics.txt", "\nHey!\n") + write("lyrics.txt", stashLevelModifiedLyrics()) true }, validator = repoPredicate { repo -> repo.stashes.isNotEmpty() && repo.files.none { it.staged } }, @@ -34,3 +26,21 @@ internal fun stashLevel(): Level = level( levelTestCase("stash changes", "git stash"), ), ) + +private fun stashLevelCommittedLyrics(): String = + """ + Down in Louisiana in that sunny clime, + They play a class of music that is super fine, + And it makes no difference if its rain or shine, + You can hear that that jazz band music playing all the time. + It sounds so peculiar cause the music's queer. + How its sweet vibration seem to fill the air. + Then to you the whole world seems to be in rhyme. + You want nothing else but blues-band music all the time. + + Ev'ry one that's nigh + Never seems to sigh, + Hear them loudly cry: + """.trimIndent() + "\n" + +private fun stashLevelModifiedLyrics(): String = stashLevelCommittedLyrics() + "Hey!\n" diff --git a/app/src/test/java/solutions/tretter/githugandroid/LevelSolutionsTest.kt b/app/src/test/java/solutions/tretter/githugandroid/LevelSolutionsTest.kt index 100ef61..fe23f41 100644 --- a/app/src/test/java/solutions/tretter/githugandroid/LevelSolutionsTest.kt +++ b/app/src/test/java/solutions/tretter/githugandroid/LevelSolutionsTest.kt @@ -98,6 +98,52 @@ class LevelSolutionsTest { ) } + @Test + fun upstreamFixtureBackedLevelsExposeSourceShapedSetup() { + 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 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, + ) + + 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()