Require real merge commit and keep level tests embedded
This commit is contained in:
@@ -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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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.
|
- `stage_lines`: model partial staged vs unstaged hunks.
|
||||||
- `merge_squash`: verify the exact squashed file/content effects.
|
- `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".
|
- `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.
|
- `contribute`, `clone`, `clone_to_folder`: current Android behavior intentionally avoids real network-dependent validation.
|
||||||
|
|||||||
@@ -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. |
|
| `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. |
|
| `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. |
|
| `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
|
## Level Authoring
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ android {
|
|||||||
applicationId = "solutions.tretter.githugandroid"
|
applicationId = "solutions.tretter.githugandroid"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 162
|
versionCode = 163
|
||||||
versionName = "0.1.161"
|
versionName = "0.1.162"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables.useSupportLibrary = true
|
vectorDrawables.useSupportLibrary = true
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ data class CommitNode(
|
|||||||
val id: String,
|
val id: String,
|
||||||
val message: String,
|
val message: String,
|
||||||
val authorTimestampSeconds: Long? = null,
|
val authorTimestampSeconds: Long? = null,
|
||||||
|
val parentCount: Int = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class InteractiveAddSession(
|
data class InteractiveAddSession(
|
||||||
@@ -57,6 +58,8 @@ data class Level(
|
|||||||
val setup: () -> RepoState,
|
val setup: () -> RepoState,
|
||||||
val nativeSetup: (NativeLevelSetup.() -> Boolean)? = null,
|
val nativeSetup: (NativeLevelSetup.() -> Boolean)? = null,
|
||||||
val testCases: List<LevelTestCase> = emptyList(),
|
val testCases: List<LevelTestCase> = emptyList(),
|
||||||
|
val negativeTestCases: List<LevelTestCase> = emptyList(),
|
||||||
|
val setupChecks: List<LevelSetupCheck> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
data class LevelTestCase(
|
data class LevelTestCase(
|
||||||
@@ -64,4 +67,10 @@ data class LevelTestCase(
|
|||||||
val commands: List<String>,
|
val commands: List<String>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class LevelSetupCheck(
|
||||||
|
val name: String,
|
||||||
|
val failureMessage: String,
|
||||||
|
val predicate: (RepoState) -> Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
fun sampleLevels(): List<Level> = allGithugLevels()
|
fun sampleLevels(): List<Level> = allGithugLevels()
|
||||||
|
|||||||
@@ -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 branchResult = runGit(nativeGit, sandbox, listOf("branch", "--list"))
|
||||||
val remoteBranchResult = runGit(nativeGit, sandbox, listOf("branch", "-r", "--list"))
|
val remoteBranchResult = runGit(nativeGit, sandbox, listOf("branch", "-r", "--list"))
|
||||||
val tagResult = runGit(nativeGit, sandbox, listOf("tag", "--list"))
|
val tagResult = runGit(nativeGit, sandbox, listOf("tag", "--list"))
|
||||||
@@ -453,14 +453,16 @@ class GitRepositoryRuntime private constructor(
|
|||||||
)
|
)
|
||||||
val commits = if (logResult.exitCode == 0) {
|
val commits = if (logResult.exitCode == 0) {
|
||||||
logResult.outputLines.filter { it.isNotBlank() }.mapNotNull { line ->
|
logResult.outputLines.filter { it.isNotBlank() }.mapNotNull { line ->
|
||||||
val parts = line.split('\t', limit = 3)
|
val parts = line.split('\t', limit = 4)
|
||||||
if (parts.isEmpty()) {
|
if (parts.isEmpty()) {
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
|
val parentHashes = parts.getOrNull(2).orEmpty().split(Regex("\\s+")).filter { it.isNotBlank() }
|
||||||
CommitNode(
|
CommitNode(
|
||||||
id = parts[0],
|
id = parts[0],
|
||||||
authorTimestampSeconds = parts.getOrNull(1)?.toLongOrNull(),
|
authorTimestampSeconds = parts.getOrNull(1)?.toLongOrNull(),
|
||||||
message = parts.getOrElse(2) { "" },
|
parentCount = parentHashes.size,
|
||||||
|
message = parts.getOrElse(3) { "" },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ val RepoStateSaver = listSaver<RepoState, Any>(
|
|||||||
state.initialized,
|
state.initialized,
|
||||||
state.headBranch,
|
state.headBranch,
|
||||||
state.files.flatMap { listOf(it.name, it.content, it.staged.toString(), it.tracked.toString(), it.deleted.toString()) },
|
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.branches.flatMap { listOf(it.key, it.value.toString()) },
|
||||||
state.currentDir,
|
state.currentDir,
|
||||||
state.tags,
|
state.tags,
|
||||||
@@ -86,11 +93,21 @@ val RepoStateSaver = listSaver<RepoState, Any>(
|
|||||||
)
|
)
|
||||||
|
|
||||||
private fun List<*>.restoreCommitNodes(): List<CommitNode> {
|
private fun List<*>.restoreCommitNodes(): List<CommitNode> {
|
||||||
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
|
val timestamp = chunk.getOrNull(2) as? String
|
||||||
timestamp.isNullOrEmpty() || timestamp.toLongOrNull()?.let { it >= 100_000_000L } == true
|
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 ->
|
return chunked(width).mapNotNull { chunk ->
|
||||||
val id = chunk.getOrNull(0) as? String ?: return@mapNotNull null
|
val id = chunk.getOrNull(0) as? String ?: return@mapNotNull null
|
||||||
@@ -98,7 +115,12 @@ private fun List<*>.restoreCommitNodes(): List<CommitNode> {
|
|||||||
CommitNode(
|
CommitNode(
|
||||||
id = id,
|
id = id,
|
||||||
message = message,
|
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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,4 +92,13 @@ internal fun bisectLevel(): Level = level(
|
|||||||
"c8c7c00",
|
"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",
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -99,6 +99,14 @@ internal fun blameLevel(): Level = level(
|
|||||||
testCases = listOf(
|
testCases = listOf(
|
||||||
levelTestCase("answer author", "Spider Man"),
|
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 =
|
private fun blameLevelFinalConfigRb(): String =
|
||||||
|
|||||||
@@ -61,6 +61,16 @@ internal fun cherryPickLevel(): Level = level(
|
|||||||
testCases = listOf(
|
testCases = listOf(
|
||||||
levelTestCase("cherry pick README commit", "git cherry-pick new-feature~2"),
|
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 =
|
private fun cherryPickLevelHardcoreMathJs(): String =
|
||||||
|
|||||||
@@ -23,4 +23,7 @@ internal fun commitInFutureLevel(): Level = level(
|
|||||||
testCases = listOf(
|
testCases = listOf(
|
||||||
levelTestCase("commit with date option", "git commit --date 2037-01-01T00:00:00+0000 -m \"Future commit\""),
|
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\""),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ internal fun conflictLevel(): Level = level(
|
|||||||
validator = repoPredicate { repo ->
|
validator = repoPredicate { repo ->
|
||||||
val poem = repo.files.find { it.name == "poem.txt" }?.content.orEmpty()
|
val poem = repo.files.find { it.name == "poem.txt" }?.content.orEmpty()
|
||||||
repo.headBranch == "master" &&
|
repo.headBranch == "master" &&
|
||||||
"merge" in repo.maintenanceActions &&
|
repo.commits.firstOrNull()?.parentCount == 2 &&
|
||||||
"Sat on a wall" in poem &&
|
"Sat on a wall" in poem &&
|
||||||
poem.none { it in "<>=|" }
|
poem.none { it in "<>=|" }
|
||||||
},
|
},
|
||||||
@@ -57,6 +57,27 @@ internal fun conflictLevel(): Level = level(
|
|||||||
"git commit --no-edit",
|
"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 =
|
private fun conflictLevelInitialPoem(): String =
|
||||||
@@ -88,3 +109,5 @@ private fun conflictLevelMasterPoem(): String =
|
|||||||
Humpty dumpty
|
Humpty dumpty
|
||||||
Had a great fall
|
Had a great fall
|
||||||
""".trimIndent() + "\n"
|
""".trimIndent() + "\n"
|
||||||
|
|
||||||
|
private fun RepoState.fileContent(path: String): String = files.firstOrNull { it.name == path }?.content.orEmpty()
|
||||||
|
|||||||
@@ -33,4 +33,13 @@ internal fun deleteBranchLevel(): Level = level(
|
|||||||
levelTestCase("delete branch", "git branch -d delete_me"),
|
levelTestCase("delete branch", "git branch -d delete_me"),
|
||||||
levelTestCase("force 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")
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ internal fun diffLevel(): Level = level(
|
|||||||
testCases = listOf(
|
testCases = listOf(
|
||||||
levelTestCase("answer changed line", "26"),
|
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 =
|
internal fun diffLevelBaselineAppRb(): String =
|
||||||
|
|||||||
@@ -49,4 +49,7 @@ internal fun fetchLevel(): Level = level(
|
|||||||
levelTestCase("fetch origin", "git fetch origin"),
|
levelTestCase("fetch origin", "git fetch origin"),
|
||||||
levelTestCase("fetch default", "git fetch"),
|
levelTestCase("fetch default", "git fetch"),
|
||||||
),
|
),
|
||||||
|
negativeTestCases = listOf(
|
||||||
|
levelTestCase("pulling does not solve fetch", "git pull"),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -47,4 +47,13 @@ internal fun findOldBranchLevel(): Level = level(
|
|||||||
testCases = listOf(
|
testCases = listOf(
|
||||||
levelTestCase("checkout old branch", "git checkout solve_world_hunger"),
|
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")
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,6 +35,15 @@ internal fun grepLevel(): Level = level(
|
|||||||
testCases = listOf(
|
testCases = listOf(
|
||||||
levelTestCase("answer todo count", "4"),
|
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 =
|
private fun grepLevelAppRb(): String =
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ internal fun level(
|
|||||||
setup: () -> RepoState,
|
setup: () -> RepoState,
|
||||||
validator: (RepoState, String) -> Boolean,
|
validator: (RepoState, String) -> Boolean,
|
||||||
testCases: List<LevelTestCase> = emptyList(),
|
testCases: List<LevelTestCase> = emptyList(),
|
||||||
|
negativeTestCases: List<LevelTestCase> = emptyList(),
|
||||||
|
setupChecks: List<LevelSetupCheck> = emptyList(),
|
||||||
nativeSetup: (NativeLevelSetup.() -> Boolean)? = null,
|
nativeSetup: (NativeLevelSetup.() -> Boolean)? = null,
|
||||||
): Level = Level(
|
): Level = Level(
|
||||||
id = id,
|
id = id,
|
||||||
@@ -79,6 +81,8 @@ internal fun level(
|
|||||||
setup = setup,
|
setup = setup,
|
||||||
nativeSetup = nativeSetup,
|
nativeSetup = nativeSetup,
|
||||||
testCases = testCases,
|
testCases = testCases,
|
||||||
|
negativeTestCases = negativeTestCases,
|
||||||
|
setupChecks = setupChecks,
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun levelTestCase(name: String, vararg commands: String): LevelTestCase = LevelTestCase(
|
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(),
|
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 ->
|
internal fun commandAnswer(vararg answers: String): (RepoState, String) -> Boolean = { _, command ->
|
||||||
val normalized = command.trim()
|
val normalized = command.trim()
|
||||||
val matched = answers.firstOrNull { it.equals(normalized, ignoreCase = true) }
|
val matched = answers.firstOrNull { it.equals(normalized, ignoreCase = true) }
|
||||||
|
|||||||
@@ -32,4 +32,7 @@ internal fun mergeLevel(): Level = level(
|
|||||||
testCases = listOf(
|
testCases = listOf(
|
||||||
levelTestCase("merge feature", "git merge feature"),
|
levelTestCase("merge feature", "git merge feature"),
|
||||||
),
|
),
|
||||||
|
negativeTestCases = listOf(
|
||||||
|
levelTestCase("switching to feature does not solve", "git switch feature"),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -33,4 +33,7 @@ internal fun reorderLevel(): Level = level(
|
|||||||
"GIT_SEQUENCE_EDITOR=\"sed -i '2{h;d};3{G}'\" git rebase -i HEAD~3",
|
"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"),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ internal fun stashLevel(): Level = level(
|
|||||||
testCases = listOf(
|
testCases = listOf(
|
||||||
levelTestCase("stash changes", "git stash"),
|
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 =
|
private fun stashLevelCommittedLyrics(): String =
|
||||||
|
|||||||
@@ -40,4 +40,12 @@ internal fun statusLevel(): Level = level(
|
|||||||
testCases = listOf(
|
testCases = listOf(
|
||||||
levelTestCase("answer untracked file", "database.yml"),
|
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")
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package solutions.tretter.githugandroid
|
package solutions.tretter.githugandroid
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -99,104 +98,54 @@ class LevelSolutionsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun upstreamFixtureBackedLevelsExposeSourceShapedSetup() {
|
fun embeddedSetupChecksPass() {
|
||||||
val gitBinary = testGitBinary()
|
val gitBinary = testGitBinary()
|
||||||
val runtimeRoot = testSandboxRoot().apply {
|
val runtimeRoot = testSandboxRoot().apply {
|
||||||
deleteRecursively()
|
deleteRecursively()
|
||||||
mkdirs()
|
mkdirs()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun prepared(level: Level): RepoState = GitRepositoryRuntime(runtimeRoot, gitBinary).prepareLevel(level)
|
val failures = allGithugLevels().flatMap { level ->
|
||||||
fun RepoState.fileContent(path: String): String = files.firstOrNull { it.name == path }?.content.orEmpty()
|
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())
|
assertTrue(
|
||||||
assertEquals("master", conflict.headBranch)
|
"Expected every embedded setup check to pass. Failures:\n${failures.joinToString("\n")}",
|
||||||
assertTrue(conflict.fileContent("poem.txt").contains("Categorized shoes by color"))
|
failures.isEmpty(),
|
||||||
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
|
@Test
|
||||||
fun reportedRegressionCommandsDoNotSolveLevels() {
|
fun embeddedNegativeScenariosDoNotSolveLevels() {
|
||||||
val statusRepo = statusLevel().setup()
|
|
||||||
val untrackedStatusFiles = statusRepo.files.filter { !it.staged && !it.tracked && !it.deleted }.map { it.name }
|
|
||||||
assertEquals(listOf("database.yml"), untrackedStatusFiles)
|
|
||||||
|
|
||||||
val gitBinary = testGitBinary()
|
val gitBinary = testGitBinary()
|
||||||
val runtimeRoot = testSandboxRoot().apply {
|
val runtimeRoot = testSandboxRoot().apply {
|
||||||
deleteRecursively()
|
deleteRecursively()
|
||||||
mkdirs()
|
mkdirs()
|
||||||
}
|
}
|
||||||
|
|
||||||
val mergeExercise = mergeLevel()
|
val failures = allGithugLevels().flatMap { level ->
|
||||||
val mergeRuntime = GitRepositoryRuntime(runtimeRoot, gitBinary)
|
level.negativeTestCases.mapNotNull { testCase ->
|
||||||
val mergeRepo = mergeRuntime.prepareLevel(mergeExercise)
|
val runtime = GitRepositoryRuntime(runtimeRoot, gitBinary)
|
||||||
val (switchedRepo, _) = mergeRuntime.execute(mergeExercise, mergeRepo, "git switch feature")
|
var repo = runtime.prepareLevel(level)
|
||||||
assertFalse("Switching to feature must not solve the merge level.", mergeExercise.validator(switchedRepo, "git switch feature"))
|
var solved = false
|
||||||
|
|
||||||
val fetchExercise = fetchLevel()
|
testCase.commands.forEach { command ->
|
||||||
val fetchRuntime = GitRepositoryRuntime(runtimeRoot, gitBinary)
|
val (nextRepo, _) = runtime.execute(level, repo, command)
|
||||||
val fetchRepo = fetchRuntime.prepareLevel(fetchExercise)
|
repo = nextRepo
|
||||||
val (pulledRepo, _) = fetchRuntime.execute(fetchExercise, fetchRepo, "git pull")
|
solved = level.validator(repo, command)
|
||||||
assertFalse("Pulling must not solve the fetch level.", fetchExercise.validator(pulledRepo, "git pull"))
|
}
|
||||||
|
|
||||||
val reorderExercise = reorderLevel()
|
if (solved) "${level.id} / ${testCase.name}: ${testCase.commands.joinToString(" && ")}" else null
|
||||||
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
|
|
||||||
}
|
}
|
||||||
val (bisectRunRepo, _) = bisectRuntime.execute(bisectExercise, bisectRepo, "git bisect run ./test-balance.sh")
|
|
||||||
assertFalse(
|
assertTrue(
|
||||||
"Running bisect should not solve the bisect level until the learner enters the last good commit hash.",
|
"Expected every embedded negative scenario to remain unsolved. Failures:\n${failures.joinToString("\n")}",
|
||||||
bisectExercise.validator(bisectRunRepo, "git bisect run ./test-balance.sh"),
|
failures.isEmpty(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,7 +230,7 @@ class LevelSolutionsTest {
|
|||||||
appendLine(" <none>")
|
appendLine(" <none>")
|
||||||
} else {
|
} else {
|
||||||
commits.forEach { commit ->
|
commits.forEach { commit ->
|
||||||
appendLine(" ${commit.id} ${commit.authorTimestampSeconds ?: "unknown-time"} ${commit.message}")
|
appendLine(" ${commit.id} parents=${commit.parentCount} ${commit.authorTimestampSeconds ?: "unknown-time"} ${commit.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user