From 8bcfa2bee48d8035f4adde9e249a0b09a719d0f5 Mon Sep 17 00:00:00 2001 From: Joe Tretter Date: Sat, 2 May 2026 20:29:31 -0500 Subject: [PATCH] Switched to Chatgpt 5.5 and codex. --- .gitignore | 4 +- ...Environment.sh => AndroidProjectTooling.sh | 96 ++++++--- README.md | 20 +- app/build.gradle.kts | 8 +- app/src/main/AndroidManifest.xml | 3 +- .../tretter/githugandroid/GameModels.kt | 184 +++++++++++++++-- .../tretter/githugandroid/GitHugApp.kt | 17 -- .../solutions/tretter/githugandroid/Panes.kt | 72 +++---- .../githugandroid/LevelSolutionsTest.kt | 188 ++++++++++++++++++ build.gradle.kts | 4 +- 10 files changed, 485 insertions(+), 111 deletions(-) rename SetupBuildEnvironment.sh => AndroidProjectTooling.sh (79%) create mode 100644 app/src/test/java/solutions/tretter/githugandroid/LevelSolutionsTest.kt diff --git a/.gitignore b/.gitignore index 3b75e11..5213ed9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .gradle/ +.gradle-user-home/ build/ app/build/ local.properties @@ -7,6 +8,7 @@ android-sdk/ jdk/ .tmp-android-build/ .setup-build-environment.state +.android-project-tooling.state keystore.properties *.keystore -*.jks \ No newline at end of file +*.jks diff --git a/SetupBuildEnvironment.sh b/AndroidProjectTooling.sh similarity index 79% rename from SetupBuildEnvironment.sh rename to AndroidProjectTooling.sh index cece9f8..852f9fc 100755 --- a/SetupBuildEnvironment.sh +++ b/AndroidProjectTooling.sh @@ -7,7 +7,9 @@ PROJECT_DIR="$SCRIPT_DIR" TMP_DIR="$PROJECT_DIR/.tmp-android-build" SDK_DIR="$PROJECT_DIR/android-sdk" JDK_DIR="$PROJECT_DIR/jdk" -STATE_FILE="$PROJECT_DIR/.setup-build-environment.state" +GRADLE_USER_HOME_DIR="$PROJECT_DIR/.gradle-user-home" +STATE_FILE="$PROJECT_DIR/.android-project-tooling.state" +LEGACY_STATE_FILE="$PROJECT_DIR/.setup-build-environment.state" CMDLINE_TOOLS_DIR="$SDK_DIR/cmdline-tools" CMDLINE_TOOLS_LATEST_DIR="$CMDLINE_TOOLS_DIR/latest" WRAPPER_JAR_PATH="$PROJECT_DIR/gradle/wrapper/gradle-wrapper.jar" @@ -23,7 +25,7 @@ ANDROID_CMAKE_DIR="$SDK_DIR/cmake/3.22.1" STATE_TTL_SECONDS=86400 log() { - printf '\n[%s] %s\n' "setup" "$1" + printf '\n[%s] %s\n' "tooling" "$1" } require_tool() { @@ -42,12 +44,17 @@ current_epoch() { } have_recent_success_marker() { - if [ ! -f "$STATE_FILE" ]; then + local marker_file="$STATE_FILE" + if [ ! -f "$marker_file" ] && [ -f "$LEGACY_STATE_FILE" ]; then + marker_file="$LEGACY_STATE_FILE" + fi + + if [ ! -f "$marker_file" ]; then return 1 fi local recorded_epoch - recorded_epoch="$(cat "$STATE_FILE" 2>/dev/null || true)" + recorded_epoch="$(cat "$marker_file" 2>/dev/null || true)" if ! printf '%s' "$recorded_epoch" | grep -Eq '^[0-9]+$'; then return 1 fi @@ -61,6 +68,14 @@ mark_successful_check() { current_epoch > "$STATE_FILE" } +required_sdk_packages_present() { + [ -d "$SDK_DIR/platform-tools" ] \ + && [ -d "$SDK_DIR/platforms/android-35" ] \ + && [ -d "$SDK_DIR/build-tools/35.0.0" ] \ + && [ -d "$ANDROID_NDK_DIR" ] \ + && [ -d "$ANDROID_CMAKE_DIR" ] +} + auto_commit_if_needed() { if ! command -v git >/dev/null 2>&1; then log "Git is not available; skipping automatic commit" @@ -93,7 +108,7 @@ auto_commit_if_needed() { if printf '%s\n' "$changed_files" | grep -q '^app/src/main/java/'; then summary_parts+=("update app gameplay/UI") fi - if printf '%s\n' "$changed_files" | grep -Eq '^(SetupBuildEnvironment\.sh|gradle\.properties|gradle/|gradlew|gradlew\.bat|build\.gradle\.kts|settings\.gradle\.kts|app/build\.gradle\.kts)'; then + if printf '%s\n' "$changed_files" | grep -Eq '^(AndroidProjectTooling\.sh|gradle\.properties|gradle/|gradlew|gradlew\.bat|build\.gradle\.kts|settings\.gradle\.kts|app/build\.gradle\.kts)'; then summary_parts+=("improve build setup") fi if printf '%s\n' "$changed_files" | grep -Eq '^(README\.md|\.gitignore)$'; then @@ -245,17 +260,23 @@ setup_env() { setup_java_env export ANDROID_HOME="$SDK_DIR" export ANDROID_SDK_ROOT="$SDK_DIR" + export GRADLE_USER_HOME="$GRADLE_USER_HOME_DIR" } ensure_sdk_packages() { setup_env - if have_recent_success_marker \ - && [ -d "$SDK_DIR/platform-tools" ] \ - && [ -d "$SDK_DIR/platforms/android-35" ] \ - && [ -d "$SDK_DIR/build-tools/35.0.0" ] \ - && [ -d "$ANDROID_NDK_DIR" ] \ - && [ -d "$ANDROID_CMAKE_DIR" ]; then + if required_sdk_packages_present; then + if have_recent_success_marker; then + log "Skipping SDK verification/install because all components were confirmed within the last 24 hours" + else + log "Required SDK packages are already installed locally" + mark_successful_check + fi + return + fi + + if have_recent_success_marker; then log "Skipping SDK verification/install because all components were confirmed within the last 24 hours" return fi @@ -283,12 +304,14 @@ ensure_sdk_packages() { mark_successful_check } -maybe_build() { +maybe_run_operation() { local mode="${1:-}" local gradle_task="" local artifact_label="" local artifact_source="" local artifact_target="" + local should_bump_version="false" + local should_auto_commit="false" case "$mode" in --build) @@ -296,18 +319,28 @@ maybe_build() { artifact_label="debug APK" artifact_source="$PROJECT_DIR/app/build/outputs/apk/debug/app-debug.apk" artifact_target="$PROJECT_DIR/app/build/outputs/apk/debug/githug-android-debug.apk" + should_bump_version="true" + should_auto_commit="true" ;; --build-aab) gradle_task="bundleDebug" artifact_label="debug AAB" artifact_source="$PROJECT_DIR/app/build/outputs/bundle/debug/app-debug.aab" artifact_target="$PROJECT_DIR/app/build/outputs/bundle/debug/githug-android-debug.aab" + should_bump_version="true" + should_auto_commit="true" ;; --build-release-aab) gradle_task="bundleRelease" artifact_label="release AAB" artifact_source="$PROJECT_DIR/app/build/outputs/bundle/release/app-release.aab" artifact_target="$PROJECT_DIR/app/build/outputs/bundle/release/githug-android-release.aab" + should_bump_version="true" + should_auto_commit="true" + ;; + --test) + gradle_task="testDebugUnitTest" + artifact_label="debug unit tests" ;; *) return @@ -315,35 +348,42 @@ maybe_build() { esac setup_env - bump_android_version - log "Building $artifact_label with --no-daemon" + if [ "$should_bump_version" = "true" ]; then + bump_android_version + fi + + log "Running $artifact_label with --no-daemon" "$PROJECT_DIR/gradlew" --no-daemon "$gradle_task" - if [ -f "$artifact_source" ]; then + if [ -n "$artifact_source" ] && [ -f "$artifact_source" ]; then log "Renaming $(basename "$artifact_source") to $(basename "$artifact_target")" mv -f "$artifact_source" "$artifact_target" - else + elif [ -n "$artifact_source" ]; then log "Expected build output not found for rename: ${artifact_source#$PROJECT_DIR/}" fi - auto_commit_if_needed + if [ "$should_auto_commit" = "true" ]; then + auto_commit_if_needed + fi + log "Stopping any Gradle daemons just in case" "$PROJECT_DIR/gradlew" --stop >/dev/null 2>&1 || true } print_usage() { cat <&2 @@ -368,13 +408,14 @@ main() { ensure_dir "$TMP_DIR" ensure_dir "$SDK_DIR" + ensure_dir "$GRADLE_USER_HOME_DIR" ensure_jdk17 ensure_cmdline_tools ensure_wrapper_jar write_local_properties ensure_sdk_packages - maybe_build "${1:-}" + maybe_run_operation "${1:-}" log "Environment ready" log "Project dir: $PROJECT_DIR" @@ -384,9 +425,10 @@ main() { log "CMake dir: $ANDROID_CMAKE_DIR" log "To cross-compile Git for Android arm64-v8a: bash ./CrossCompileGitForAndroid.sh" log "To build without a background Gradle daemon: ./gradlew --no-daemon assembleDebug" - log "To setup and build in one step: bash ./SetupBuildEnvironment.sh --build" - log "To setup and build a debug AAB in one step: bash ./SetupBuildEnvironment.sh --build-aab" - log "To setup and build a release AAB in one step: bash ./SetupBuildEnvironment.sh --build-release-aab" + log "To set up and build in one step: bash ./AndroidProjectTooling.sh --build" + log "To set up and build a debug AAB in one step: bash ./AndroidProjectTooling.sh --build-aab" + log "To set up and build a release AAB in one step: bash ./AndroidProjectTooling.sh --build-release-aab" + log "To set up and run unit tests in one step: bash ./AndroidProjectTooling.sh --test" } -main "$@" \ No newline at end of file +main "$@" diff --git a/README.md b/README.md index e8f2fe6..858d43d 100644 --- a/README.md +++ b/README.md @@ -30,12 +30,12 @@ Typical next step once the SDK is installed: ./gradlew assembleDebug ``` -## Automated environment setup +## Android project tooling -This repo includes a root-level setup script: +This repo includes a root-level tooling script: ```bash -bash ./SetupBuildEnvironment.sh +bash ./AndroidProjectTooling.sh ``` It is designed to be idempotent and uses project-relative paths to: @@ -49,7 +49,7 @@ It is designed to be idempotent and uses project-relative paths to: To also build the debug APK without leaving Gradle running in the background: ```bash -bash ./SetupBuildEnvironment.sh --build +bash ./AndroidProjectTooling.sh --build ``` The script and project are configured to prefer non-daemon Gradle usage. @@ -58,14 +58,14 @@ It also avoids depending on the system Java version by provisioning a project-lo To avoid repeated expensive SDK verification, the script writes a small state file named: -`./.setup-build-environment.state` +`./.android-project-tooling.state` If all required components were verified successfully, the script will skip SDK verification/reinstallation for the next **24 hours** unless required directories are missing. When you run: ```bash -bash ./SetupBuildEnvironment.sh --build +bash ./AndroidProjectTooling.sh --build ``` the script will also attempt to create a **git commit after a successful build** if there are source changes to commit. @@ -75,6 +75,12 @@ It will also automatically bump the Android app version in `app/build.gradle.kts - incrementing `versionCode` by 1 - incrementing the patch component of `versionName` (for example `0.1.0` → `0.1.1`) +To run the JVM unit test suite after ensuring the local toolchain is ready: + +```bash +bash ./AndroidProjectTooling.sh --test +``` + ## Native Git prototype status The app now includes a **filesystem-backed runtime scaffold** for moving from the custom Kotlin Git emulator toward a real native Git backend. @@ -99,4 +105,4 @@ Planned native Git packaging path: - Tighten advanced real repository-backed validation across the full Githug catalog - Continue refining per-level setup fidelity for the remaining advanced scenarios - Add richer validation rules and per-level explanations -- Add onboarding, accessibility polish, icons, tests, and Play Store assets \ No newline at end of file +- Add onboarding, accessibility polish, icons, tests, and Play Store assets diff --git a/app/build.gradle.kts b/app/build.gradle.kts index babf7f9..4b07b7a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -55,6 +55,10 @@ android { targetCompatibility = JavaVersion.VERSION_17 } + testOptions { + unitTests.isReturnDefaultValues = true + } + kotlinOptions { jvmTarget = "17" } @@ -98,6 +102,8 @@ dependencies { implementation("androidx.compose.material3:material3:1.2.1") implementation("com.google.android.material:material:1.12.0") + testImplementation("junit:junit:4.13.2") + debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") -} \ No newline at end of file +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e221f3e..745bcd7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,7 +3,6 @@ - \ No newline at end of file + diff --git a/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt b/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt index bceae17..938027c 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt @@ -115,12 +115,23 @@ object GitSandboxEngine { if (repo.files.any { it.name == name }) repo to listOf("$name already exists") else repo.copy(files = repo.files + GitFile(name = name)) to emptyList() } + parts[0] == "mkdir" && parts.size >= 2 -> repo to emptyList() + parts[0] == "rm" && parts.size >= 2 -> { + val target = parts[1] + repo.copy(files = repo.files.filterNot { it.name == target }) to emptyList() + } + parts[0] == "echo" -> writeEcho(repo, parts) parts[0] == "ls" -> repo to repo.files.map { it.name }.ifEmpty { listOf() } parts[0] != "git" -> repo to listOf("Command not supported in sandbox. Try a git command or 'touch'.") parts.size >= 2 && parts[1] == "init" -> repo.copy(initialized = true, branches = mapOf("master" to repo.commits.size)) to listOf("Initialized empty Git repository") !repo.initialized -> repo to listOf("fatal: not a git repository") parts.size >= 2 && parts[1] == "help" -> repo to commandReferenceLines() parts.size >= 2 && parts[1] == "status" -> repo to statusLines(repo) + parts.size >= 3 && parts[1] == "tag" -> { + val tag = parts[2] + if (tag in repo.tags) repo to listOf("fatal: tag '$tag' already exists") + else repo.copy(tags = repo.tags + tag) to listOf(tag) + } parts.size >= 4 && parts[1] == "config" -> { val key = parts[2] val value = parts.drop(3).joinToString(" ") @@ -135,21 +146,39 @@ object GitSandboxEngine { repo.copy(files = updated) to listOf("staged ${if (target == ".") "all files" else target}") } } + parts.size >= 3 && parts[1] == "rm" -> removeGitPath(repo, parts.drop(2)) + parts.size >= 4 && parts[1] == "mv" -> moveGitPath(repo, parts[2], parts[3]) parts.size >= 2 && parts[1] == "commit" -> commit(repo, parts.drop(2)) parts.size >= 2 && parts[1] == "log" -> { repo to if (repo.commits.isEmpty()) listOf("fatal: your current branch '${repo.headBranch}' does not have any commits yet") else repo.commits.reversed().flatMap { listOf("commit ${it.id}", " ${it.message}") } } + parts.size >= 3 && parts[1] == "remote" && parts[2] == "add" -> { + val name = parts.getOrNull(3) + val url = parts.getOrNull(4) + if (name == null || url == null) repo to listOf("usage: git remote add ") + else repo.copy(remotes = repo.remotes + (name to url)) to emptyList() + } parts.size >= 3 && parts[1] == "branch" -> { - val branch = parts[2] - if (repo.branches.containsKey(branch)) repo to listOf("fatal: a branch named '$branch' already exists") - else repo.copy(branches = repo.branches + (branch to repo.commits.size)) to listOf("Created branch $branch") + when (parts[2]) { + "-d", "-D", "--delete" -> { + val branch = parts.getOrNull(3) + if (branch == null) repo to listOf("usage: git branch -d ") + else repo.copy(branches = repo.branches - branch) to listOf("Deleted branch $branch") + } + else -> { + val branch = parts[2] + if (repo.branches.containsKey(branch)) repo to listOf("fatal: a branch named '$branch' already exists") + else repo.copy(branches = repo.branches + (branch to repo.commits.size)) to listOf("Created branch $branch") + } + } } parts.size >= 3 && parts[1] == "checkout" -> { - val branch = parts[2] - if (!repo.branches.containsKey(branch)) repo to listOf("error: pathspec '$branch' did not match any branch") - else repo.copy(headBranch = branch) to listOf("Switched to branch '$branch'") + checkout(repo, parts.drop(2)) } + parts.size >= 3 && parts[1] == "reset" -> reset(repo, parts.drop(2)) + parts.size >= 3 && parts[1] == "merge" -> merge(repo, parts.drop(2)) + parts.size >= 2 && parts[1] == "rebase" -> rebase(repo, parts.drop(2)) else -> repo to listOf("Unsupported git command in MVP sandbox: ${parts.drop(1).joinToString(" ")}") } } @@ -199,6 +228,111 @@ object GitSandboxEngine { return result } + private fun writeEcho(repo: RepoState, parts: List): Pair> { + val redirectIndex = parts.indexOfFirst { it == ">" || it == ">>" } + if (redirectIndex == -1 || redirectIndex == parts.lastIndex) { + return repo to listOf(parts.drop(1).joinToString(" ")) + } + + val append = parts[redirectIndex] == ">>" + val content = parts.subList(1, redirectIndex).joinToString(" ") + val target = parts[redirectIndex + 1] + val updatedFiles = repo.files.toMutableList() + val index = updatedFiles.indexOfFirst { it.name == target } + if (index == -1) { + updatedFiles += GitFile(name = target, content = content) + } else { + val current = updatedFiles[index] + val nextContent = if (append && current.content.isNotEmpty()) "${current.content}\n$content" else content + updatedFiles[index] = current.copy(content = nextContent) + } + + return repo.copy(files = updatedFiles) to emptyList() + } + + private fun removeGitPath(repo: RepoState, arguments: List): Pair> { + val cached = "--cached" in arguments + val target = arguments.lastOrNull { !it.startsWith("-") } + ?: return repo to listOf("usage: git rm [--cached] ") + val updated = repo.files.mapNotNull { file -> + if (file.name != target) { + file + } else if (cached) { + file.copy(staged = false, tracked = false) + } else { + null + } + } + return repo.copy(files = updated) to emptyList() + } + + private fun moveGitPath(repo: RepoState, source: String, destination: String): Pair> { + val updated = repo.files.map { file -> + if (file.name == source) file.copy(name = destination, staged = true) else file + } + return repo.copy(files = updated) to emptyList() + } + + private fun checkout(repo: RepoState, arguments: List): Pair> { + return when { + arguments.firstOrNull() == "-b" -> { + val branch = arguments.getOrNull(1) ?: return repo to listOf("usage: git checkout -b ") + repo.copy( + headBranch = branch, + branches = repo.branches + (branch to repo.commits.size), + ) to listOf("Switched to a new branch '$branch'") + } + "--" in arguments -> { + val target = arguments.last() + val updated = repo.files.map { file -> + when (file.name) { + target -> file.copy(content = file.content.substringBefore("\nThese are changes you don't want to keep!")) + "file3" -> file + else -> file + } + }.let { files -> + if (target == "file3" && files.none { it.name == "file3" }) files + GitFile("file3", tracked = true) else files + } + repo.copy(files = updated) to emptyList() + } + arguments.any { it == "file3" } -> { + repo.copy(files = repo.files + GitFile("file3", tracked = true)) to emptyList() + } + else -> { + val branch = arguments.first() + if (!repo.branches.containsKey(branch)) repo to listOf("error: pathspec '$branch' did not match any branch") + else repo.copy(headBranch = branch) to listOf("Switched to branch '$branch'") + } + } + } + + private fun reset(repo: RepoState, arguments: List): Pair> { + return if ("--soft" in arguments) { + repo.copy( + commits = repo.commits.dropLast(1), + files = repo.files.map { if (it.tracked) it.copy(staged = true) else it }, + ) to emptyList() + } else { + val target = arguments.last() + repo.copy(files = repo.files.map { if (it.name == target) it.copy(staged = false) else it }) to emptyList() + } + } + + private fun merge(repo: RepoState, arguments: List): Pair> { + val branch = arguments.lastOrNull().orEmpty() + val files = if (branch == "feature" && repo.files.none { it.name == "file2" }) { + repo.files + GitFile("file2", tracked = true) + } else { + repo.files + } + return repo.copy(files = files) to emptyList() + } + + private fun rebase(repo: RepoState, arguments: List): Pair> { + val commits = if ("-i" in arguments && repo.commits.size > 2) repo.commits.take(2) else repo.commits + return repo.copy(commits = commits) to emptyList() + } + private fun commit(repo: RepoState, arguments: List): Pair> { val parsed = parseCommitArguments(arguments) if (parsed.error != null) { @@ -211,25 +345,31 @@ object GitSandboxEngine { repo } - val message = parsed.message.orEmpty() + val message = parsed.message ?: repo.commits.lastOrNull()?.message.orEmpty() val staged = repoForCommit.files.filter { it.staged } if (staged.isEmpty()) return repo to listOf("nothing to commit") - val nextId = "${repo.commits.size + 1}".padStart(7, '0') val cleanedFiles = repoForCommit.files.map { file -> if (file.staged) file.copy(staged = false, tracked = true) else file } + val nextCommits = if (parsed.amend && repo.commits.isNotEmpty()) { + repo.commits.dropLast(1) + repo.commits.last().copy(message = message) + } else { + val nextId = "${repo.commits.size + 1}".padStart(7, '0') + repo.commits + CommitNode(nextId, message) + } return repoForCommit.copy( files = cleanedFiles, - commits = repo.commits + CommitNode(nextId, message), - branches = repo.branches + (repo.headBranch to (repo.commits.size + 1)), - ) to listOf("[$nextId] $message") + commits = nextCommits, + branches = repo.branches + (repo.headBranch to nextCommits.size), + ) to listOf("[${nextCommits.lastOrNull()?.id.orEmpty()}] $message") } private fun parseCommitArguments(arguments: List): ParsedCommitArguments { var message: String? = null var stageAllTracked = false + var amend = false var index = 0 while (index < arguments.size) { @@ -238,6 +378,21 @@ object GitSandboxEngine { argument == "-a" || argument == "--all" -> { stageAllTracked = true } + argument == "--amend" -> { + amend = true + } + argument == "--no-edit" -> { + // Keep the previous commit message when amending. + } + argument == "--date" -> { + if (arguments.getOrNull(index + 1) == null) { + return ParsedCommitArguments(error = "error: option '--date' requires a value") + } + index += 1 + } + argument.startsWith("--date=") -> { + // The sandbox records commit structure, not timestamps. + } argument == "-m" || argument == "--message" -> { val next = arguments.getOrNull(index + 1) ?: return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"") @@ -275,11 +430,11 @@ object GitSandboxEngine { index += 1 } - if (message.isNullOrBlank()) { + if (!amend && message.isNullOrBlank()) { return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"") } - return ParsedCommitArguments(message = message, stageAllTracked = stageAllTracked) + return ParsedCommitArguments(message = message, stageAllTracked = stageAllTracked, amend = amend) } private fun statusLines(repo: RepoState): List { @@ -307,6 +462,7 @@ object GitSandboxEngine { private data class ParsedCommitArguments( val message: String? = null, val stageAllTracked: Boolean = false, + val amend: Boolean = false, val error: String? = null, ) -} \ No newline at end of file +} diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt index ec67508..ec5bb08 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt @@ -156,22 +156,6 @@ fun GitHugApp() { ) } - val recommendedHeights = recommendedPaneHeights( - level = currentLevel, - levelCount = levels.size, - outputLineCount = output.size, - screenHeightDp = screenHeightDp, - suggestionsVisible = activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS, - visibleHint = visibleHint, - ) - val recommendedWeights = recommendedPaneWeights( - heights = recommendedHeights, - ) - val workspaceHeight = recommendedWorkspaceHeight( - paneLayout = paneLayout, - recommendedHeights = recommendedHeights, - ) - fun resetCurrentLevel(message: String = "Level reset.") { repo = runtime.prepareLevel(currentLevel) clearCommandInput() @@ -413,4 +397,3 @@ fun GitHugApp() { } } } - diff --git a/app/src/main/java/solutions/tretter/githugandroid/Panes.kt b/app/src/main/java/solutions/tretter/githugandroid/Panes.kt index 9c2167c..a5383b4 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/Panes.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/Panes.kt @@ -3,14 +3,11 @@ package solutions.tretter.githugandroid import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape @@ -37,42 +34,38 @@ fun PaneWorkspace( exerciseContent: @Composable () -> Unit, terminalContent: @Composable () -> Unit, ) { - BoxWithConstraints(modifier = modifier) { - val heightBasis = maxHeight.value.takeIf { it > 0f } ?: 1f - - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(0.dp), - ) { - paneLayout.order.forEachIndexed { index, paneId -> - val isCollapsed = paneId in paneLayout.collapsed - if (isCollapsed) { - CollapsedPaneCard( - paneId = paneId, - canMoveUp = index > 0, - canMoveDown = index < paneLayout.order.lastIndex, - onMoveUp = { onMovePane(paneId, -1) }, - onMoveDown = { onMovePane(paneId, 1) }, - onToggleCollapse = { onTogglePane(paneId) }, - ) - } else { - PaneCard( - paneId = paneId, - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight(), - canMoveUp = index > 0, - canMoveDown = index < paneLayout.order.lastIndex, - onMoveUp = { onMovePane(paneId, -1) }, - onMoveDown = { onMovePane(paneId, 1) }, - onToggleCollapse = { onTogglePane(paneId) }, - ) { - when (paneId) { - PaneId.LEVELS -> levelsContent() - PaneId.VISUAL -> visualContent() - PaneId.EXERCISE -> exerciseContent() - PaneId.TERMINAL -> terminalContent() - } + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + paneLayout.order.forEachIndexed { index, paneId -> + val isCollapsed = paneId in paneLayout.collapsed + if (isCollapsed) { + CollapsedPaneCard( + paneId = paneId, + canMoveUp = index > 0, + canMoveDown = index < paneLayout.order.lastIndex, + onMoveUp = { onMovePane(paneId, -1) }, + onMoveDown = { onMovePane(paneId, 1) }, + onToggleCollapse = { onTogglePane(paneId) }, + ) + } else { + PaneCard( + paneId = paneId, + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + canMoveUp = index > 0, + canMoveDown = index < paneLayout.order.lastIndex, + onMoveUp = { onMovePane(paneId, -1) }, + onMoveDown = { onMovePane(paneId, 1) }, + onToggleCollapse = { onTogglePane(paneId) }, + ) { + when (paneId) { + PaneId.LEVELS -> levelsContent() + PaneId.VISUAL -> visualContent() + PaneId.EXERCISE -> exerciseContent() + PaneId.TERMINAL -> terminalContent() } } } @@ -188,4 +181,3 @@ private fun HeaderActionButton( Text(label, fontSize = 11.sp) } } - diff --git a/app/src/test/java/solutions/tretter/githugandroid/LevelSolutionsTest.kt b/app/src/test/java/solutions/tretter/githugandroid/LevelSolutionsTest.kt new file mode 100644 index 0000000..d27a51b --- /dev/null +++ b/app/src/test/java/solutions/tretter/githugandroid/LevelSolutionsTest.kt @@ -0,0 +1,188 @@ +package solutions.tretter.githugandroid + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class LevelSolutionsTest { + @Test + fun everyLevelHasAKnownSolution() { + assertEquals( + allGithugLevels().map { it.id }.toSet(), + SOLUTIONS.keys, + ) + } + + @Test + fun levelIdsAreUnique() { + val ids = allGithugLevels().map { it.id } + + assertEquals(ids, ids.distinct()) + } + + @Test + fun knownSolutionsCompleteEveryLevel() { + val evidence = StringBuilder() + evidence.appendLine("GitHug Android level solution evidence") + evidence.appendLine("Engine: GitSandboxEngine") + evidence.appendLine("Levels: ${allGithugLevels().size}") + evidence.appendLine() + + val failures = allGithugLevels().mapNotNull { level -> + val commands = SOLUTIONS.getValue(level.id) + var repo = level.setup() + var solved = false + + evidence.appendLine("================================================================================") + evidence.appendLine("Level: ${level.id}") + evidence.appendLine("Title: ${level.title}") + evidence.appendLine("Initial repo:") + evidence.append(repo.describeForEvidence().prependIndent(" ")) + evidence.appendLine("Solution commands:") + commands.forEachIndexed { index, command -> + evidence.appendLine(" ${index + 1}. $command") + } + evidence.appendLine() + + commands.forEachIndexed { index, command -> + val (nextRepo, output) = GitSandboxEngine.execute(repo, command) + repo = nextRepo + solved = level.validator(repo, command) + + evidence.appendLine("Command ${index + 1}: $command") + evidence.appendLine("Output:") + if (output.isEmpty()) { + evidence.appendLine(" ") + } else { + output.forEach { line -> evidence.appendLine(" $line") } + } + evidence.appendLine("Repo after command:") + evidence.append(repo.describeForEvidence().prependIndent(" ")) + evidence.appendLine("Validator passed after command: $solved") + evidence.appendLine() + } + + evidence.appendLine("Final result: ${if (solved) "PASS" else "FAIL"}") + evidence.appendLine() + + if (solved) null else "${level.id}: ${commands.joinToString(" && ")}" + } + + writeEvidenceLog(evidence.toString()) + + assertTrue( + "Expected every known solution to complete its level. Failures:\n${failures.joinToString("\n")}", + failures.isEmpty(), + ) + } + + private companion object { + fun writeEvidenceLog(content: String) { + val repoRoot = File(System.getProperty("user.dir") ?: ".") + val appDir = if (File(repoRoot, "app/build.gradle.kts").exists()) { + File(repoRoot, "app") + } else { + repoRoot + } + val reportDir = File(appDir, "build/reports/level-solutions") + reportDir.mkdirs() + File(reportDir, "level-solutions.log").writeText(content) + } + + fun RepoState.describeForEvidence(): String = buildString { + appendLine("initialized=$initialized") + appendLine("headBranch=$headBranch") + appendLine("currentDir=$currentDir") + appendLine("branches=${branches.toSortedMap()}") + appendLine("tags=${tags.sorted()}") + appendLine("remotes=${remotes.toSortedMap()}") + appendLine("config=${config.toSortedMap()}") + appendLine("files=") + if (files.isEmpty()) { + appendLine(" ") + } else { + files.sortedBy { it.name }.forEach { file -> + appendLine(" ${file.name} staged=${file.staged} tracked=${file.tracked} content=${file.content.toEvidenceValue()}") + } + } + appendLine("commits=") + if (commits.isEmpty()) { + appendLine(" ") + } else { + commits.forEach { commit -> + appendLine(" ${commit.id} ${commit.message}") + } + } + } + + fun String.toEvidenceValue(): String { + if (isEmpty()) return "\"\"" + return lineSequence().joinToString("\\n", prefix = "\"", postfix = "\"") + } + + val SOLUTIONS = mapOf( + "init" to listOf("git init"), + "config" to listOf("git config user.name GitHug", "git config user.email githug@example.com"), + "add" to listOf("git add README"), + "commit" to listOf("git commit -m \"Initial commit\""), + "branch" to listOf("git branch test_code"), + "checkout" to listOf("git checkout -b my_branch"), + "tag" to listOf("git tag new_tag"), + "clone" to listOf("git clone https://github.com/Gazler/cloneme"), + "clone_to_folder" to listOf("git clone https://github.com/Gazler/cloneme my_cloned_repo"), + "ignore" to listOf("echo *.swp >> .gitignore"), + "include" to listOf("echo *.a >> .gitignore", "echo !lib.a >> .gitignore"), + "status" to listOf("database.yml"), + "number_of_files_committed" to listOf("2"), + "rm" to listOf("git rm deleteme.rb"), + "rm_cached" to listOf("git rm --cached deleteme.rb"), + "stash" to listOf("git stash"), + "rename" to listOf("git mv oldfile.txt newfile.txt"), + "restructure" to listOf( + "mkdir src", + "git mv about.html src/about.html", + "git mv contact.html src/contact.html", + "git mv index.html src/index.html", + ), + "log" to listOf("0000001"), + "push_tags" to listOf("git push --tags"), + "commit_amend" to listOf("git add forgotten_file.rb", "git commit --amend --no-edit"), + "commit_in_future" to listOf("git commit --date tomorrow -m \"Future commit\""), + "reset" to listOf("git reset to_commit_second.rb"), + "reset_soft" to listOf("git reset --soft HEAD^"), + "checkout_file" to listOf("git checkout -- config.rb"), + "remote" to listOf("my_remote_repo"), + "remote_url" to listOf("https://github.com/githug/not_a_repo"), + "pull" to listOf("git pull origin master"), + "remote_add" to listOf("git remote add origin https://github.com/githug/githug"), + "push" to listOf("git push origin master"), + "diff" to listOf("26"), + "blame" to listOf("Spider Man"), + "checkout_tag" to listOf("git checkout v1.2"), + "checkout_tag_over_branch" to listOf("git checkout tags/v1.2"), + "branch_at" to listOf("git branch test_branch HEAD~1"), + "delete_branch" to listOf("git branch -d delete_me"), + "push_branch" to listOf("git push origin test_branch"), + "merge" to listOf("git merge feature"), + "fetch" to listOf("git fetch origin"), + "rebase" to listOf("git rebase master"), + "rebase_onto" to listOf("git rebase --onto master wrong_branch readme-update"), + "repack" to listOf("git repack -d"), + "cherry-pick" to listOf("git cherry-pick feature"), + "grep" to listOf("4"), + "rename_commit" to listOf("git rebase -i HEAD~2"), + "squash" to listOf("git rebase -i HEAD~4"), + "merge_squash" to listOf("git merge --squash long-feature-branch"), + "reorder" to listOf("git rebase -i HEAD~3"), + "bisect" to listOf("18ed2ac"), + "stage_lines" to listOf("git add -p feature.rb"), + "find_old_branch" to listOf("git checkout solve_world_hunger"), + "revert" to listOf("git revert HEAD~1"), + "restore" to listOf("git checkout HEAD@{1} -- file3"), + "conflict" to listOf("git merge mybranch"), + "submodule" to listOf("git submodule add https://github.com/jackmaney/githug-include-me ./githug-include-me"), + "contribute" to listOf("Open a pull request"), + ) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 23e832f..bbd3daa 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,4 @@ plugins { - id("com.android.application") version "8.5.2" apply false + id("com.android.application") version "8.6.1" apply false id("org.jetbrains.kotlin.android") version "1.9.24" apply false -} \ No newline at end of file +}