From 5cda0a932f08070e177d31f4758b4058a467fc3e Mon Sep 17 00:00:00 2001 From: Gabrielle Lau Date: Mon, 10 Nov 2025 11:18:59 -0800 Subject: [PATCH] Add catch the ball Android app PiperOrigin-RevId: 830525875 --- android_env/apps/MODULE.bazel | 80 ++++ .../AndroidManifest.xml | 29 +- .../AndroidManifest_lite.xml | 29 +- .../xml/accessibility_forwarder_service.xml | 29 +- .../androidenv/catch/AndroidManifest.xml | 39 ++ .../com/google/androidenv/catch/BUILD.bazel | 80 ++++ .../com/google/androidenv/catch/GameLogic.kt | 76 ++++ .../androidenv/catch/GameLogicThread.kt | 45 ++ .../google/androidenv/catch/MainActivity.kt | 152 +++++++ .../google/androidenv/catch/RenderThread.kt | 53 +++ .../androidenv/catch/res/layout/main.xml | 27 ++ .../androidenv/catch/res/values/strings.xml | 19 + .../androidenv/catch/sprite/BUILD.bazel | 64 +++ .../androidenv/catch/sprite/Background.kt | 26 ++ .../google/androidenv/catch/sprite/Ball.kt | 108 +++++ .../androidenv/catch/sprite/LineSegment.kt | 18 + .../google/androidenv/catch/sprite/Paddle.kt | 70 ++++ .../google/androidenv/catch/sprite/Point.kt | 18 + .../google/androidenv/catch/sprite/Sprite.kt | 24 ++ .../androidenv/catch/AndroidManifest.xml | 39 ++ .../com/google/androidenv/catch/BUILD.bazel | 87 ++++ .../google/androidenv/catch/GameLogicTest.kt | 195 +++++++++ .../androidenv/catch/GameLogicThreadTest.kt | 72 ++++ .../androidenv/catch/MainActivityTest.kt | 85 ++++ .../androidenv/catch/RenderThreadTest.kt | 136 ++++++ .../androidenv/catch/sprite/BUILD.bazel | 82 ++++ .../androidenv/catch/sprite/BackgroundTest.kt | 55 +++ .../androidenv/catch/sprite/BallTest.kt | 387 ++++++++++++++++++ .../androidenv/catch/sprite/PaddleTest.kt | 184 +++++++++ .../androidenv/catch/sprite/SpriteTest.kt | 55 +++ 30 files changed, 2321 insertions(+), 42 deletions(-) create mode 100644 android_env/apps/MODULE.bazel create mode 100644 android_env/apps/java/com/google/androidenv/catch/AndroidManifest.xml create mode 100644 android_env/apps/java/com/google/androidenv/catch/BUILD.bazel create mode 100644 android_env/apps/java/com/google/androidenv/catch/GameLogic.kt create mode 100644 android_env/apps/java/com/google/androidenv/catch/GameLogicThread.kt create mode 100644 android_env/apps/java/com/google/androidenv/catch/MainActivity.kt create mode 100644 android_env/apps/java/com/google/androidenv/catch/RenderThread.kt create mode 100644 android_env/apps/java/com/google/androidenv/catch/res/layout/main.xml create mode 100644 android_env/apps/java/com/google/androidenv/catch/res/values/strings.xml create mode 100644 android_env/apps/java/com/google/androidenv/catch/sprite/BUILD.bazel create mode 100644 android_env/apps/java/com/google/androidenv/catch/sprite/Background.kt create mode 100644 android_env/apps/java/com/google/androidenv/catch/sprite/Ball.kt create mode 100644 android_env/apps/java/com/google/androidenv/catch/sprite/LineSegment.kt create mode 100644 android_env/apps/java/com/google/androidenv/catch/sprite/Paddle.kt create mode 100644 android_env/apps/java/com/google/androidenv/catch/sprite/Point.kt create mode 100644 android_env/apps/java/com/google/androidenv/catch/sprite/Sprite.kt create mode 100644 android_env/apps/javatests/com/google/androidenv/catch/AndroidManifest.xml create mode 100644 android_env/apps/javatests/com/google/androidenv/catch/BUILD.bazel create mode 100644 android_env/apps/javatests/com/google/androidenv/catch/GameLogicTest.kt create mode 100644 android_env/apps/javatests/com/google/androidenv/catch/GameLogicThreadTest.kt create mode 100644 android_env/apps/javatests/com/google/androidenv/catch/MainActivityTest.kt create mode 100644 android_env/apps/javatests/com/google/androidenv/catch/RenderThreadTest.kt create mode 100644 android_env/apps/javatests/com/google/androidenv/catch/sprite/BUILD.bazel create mode 100644 android_env/apps/javatests/com/google/androidenv/catch/sprite/BackgroundTest.kt create mode 100644 android_env/apps/javatests/com/google/androidenv/catch/sprite/BallTest.kt create mode 100644 android_env/apps/javatests/com/google/androidenv/catch/sprite/PaddleTest.kt create mode 100644 android_env/apps/javatests/com/google/androidenv/catch/sprite/SpriteTest.kt diff --git a/android_env/apps/MODULE.bazel b/android_env/apps/MODULE.bazel new file mode 100644 index 00000000..80d50ec2 --- /dev/null +++ b/android_env/apps/MODULE.bazel @@ -0,0 +1,80 @@ +# Copyright 2025 DeepMind Technologies Limited. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Bazel dependencies for building Catch. +module( + name = "catch", + version = "1.0", +) + +bazel_dep(name = "rules_android", version = "0.6.6") +bazel_dep(name = "rules_kotlin", version = "2.1.8") +bazel_dep(name = "rules_jvm_external", version = "6.7") +bazel_dep(name = "rules_robolectric", version = "4.16", repo_name = "robolectric") +bazel_dep(name = "rules_java", version = "9.0.3") +bazel_dep(name = "protobuf", version = "30.0") + +# To avoid conflict with different protobuf versions. +single_version_override( + module_name = "protobuf", + version = "30.0", +) + +maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") + +# Need to set testonly = True because the package depends on testonly targets. +maven.artifact( + testonly = True, + artifact = "runner", + group = "androidx.test", + version = "1.7.0", +) +maven.artifact( + testonly = True, + artifact = "junit", + group = "androidx.test.ext", + version = "1.3.0", +) +maven.artifact( + testonly = True, + artifact = "mockito-kotlin", + group = "org.mockito.kotlin", + version = "6.1.0", +) +maven.install( + artifacts = [ + "androidx.test.ext:junit:1.3.0", + "androidx.test:runner:1.7.0", + "com.google.guava:guava:32.0.1-jre", + "com.google.truth:truth:1.4.0", + "org.mockito.kotlin:mockito-kotlin:6.1.0", + "org.mockito:mockito-core:5.20.0", + "org.robolectric:robolectric:4.16", + "org.yaml:snakeyaml:2.5", + ], + repositories = [ + "https://maven.google.com", + "https://repo1.maven.org/maven2", + ], +) +use_repo(maven, "maven") + +remote_android_extensions = use_extension( + "@rules_android//bzlmod_extensions:android_extensions.bzl", + "remote_android_tools_extensions", +) +use_repo(remote_android_extensions, "android_tools") + +android_sdk_repository_extension = use_extension("@rules_android//rules/android_sdk_repository:rule.bzl", "android_sdk_repository_extension") +use_repo(android_sdk_repository_extension, "androidsdk") diff --git a/android_env/apps/java/com/google/androidenv/accessibilityforwarder/AndroidManifest.xml b/android_env/apps/java/com/google/androidenv/accessibilityforwarder/AndroidManifest.xml index 613d5a67..74651bd0 100644 --- a/android_env/apps/java/com/google/androidenv/accessibilityforwarder/AndroidManifest.xml +++ b/android_env/apps/java/com/google/androidenv/accessibilityforwarder/AndroidManifest.xml @@ -1,18 +1,19 @@ -// Copyright 2025 DeepMind Technologies Limited. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - + + + diff --git a/android_env/apps/java/com/google/androidenv/accessibilityforwarder/AndroidManifest_lite.xml b/android_env/apps/java/com/google/androidenv/accessibilityforwarder/AndroidManifest_lite.xml index f9d1cd3d..0709ab3a 100644 --- a/android_env/apps/java/com/google/androidenv/accessibilityforwarder/AndroidManifest_lite.xml +++ b/android_env/apps/java/com/google/androidenv/accessibilityforwarder/AndroidManifest_lite.xml @@ -1,18 +1,19 @@ -// Copyright 2025 DeepMind Technologies Limited. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - + + + diff --git a/android_env/apps/java/com/google/androidenv/accessibilityforwarder/res/xml/accessibility_forwarder_service.xml b/android_env/apps/java/com/google/androidenv/accessibilityforwarder/res/xml/accessibility_forwarder_service.xml index 65d9ef09..a396a152 100644 --- a/android_env/apps/java/com/google/androidenv/accessibilityforwarder/res/xml/accessibility_forwarder_service.xml +++ b/android_env/apps/java/com/google/androidenv/accessibilityforwarder/res/xml/accessibility_forwarder_service.xml @@ -1,18 +1,19 @@ -// Copyright 2025 DeepMind Technologies Limited. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - + + + + + + + + + + + + + + + + + diff --git a/android_env/apps/java/com/google/androidenv/catch/BUILD.bazel b/android_env/apps/java/com/google/androidenv/catch/BUILD.bazel new file mode 100644 index 00000000..ef6084e7 --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/catch/BUILD.bazel @@ -0,0 +1,80 @@ +# Copyright 2025 DeepMind Technologies Limited. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Classic RL task implemented as an Android app. +load("@rules_android//rules:rules.bzl", "android_binary") +load("@rules_kotlin//kotlin:android.bzl", "kt_android_library") + +package( + default_visibility = [":catch_packages"], +) + +package_group( + name = "catch_packages", + packages = [ + "//java/com/google/androidenv/catch/...", + "//javatests/com/google/androidenv/catch/...", + ], +) + +licenses(["notice"]) + +android_binary( + name = "app", + manifest = "AndroidManifest.xml", + multidex = "native", + deps = [":MainActivity"], +) + +kt_android_library( + name = "GameLogic", + srcs = ["GameLogic.kt"], + deps = [ + "//java/com/google/androidenv/catch/sprite:Background", + "//java/com/google/androidenv/catch/sprite:Ball", + "//java/com/google/androidenv/catch/sprite:LineSegment", + "//java/com/google/androidenv/catch/sprite:Paddle", + ], +) + +kt_android_library( + name = "GameLogicThread", + srcs = ["GameLogicThread.kt"], + deps = [ + ":GameLogic", + ], +) + +kt_android_library( + name = "MainActivity", + srcs = ["MainActivity.kt"], + manifest = "AndroidManifest.xml", + resource_files = glob(["res/**"]), + deps = [ + ":GameLogic", + ":GameLogicThread", + ":RenderThread", + "//java/com/google/androidenv/catch/sprite:Background", + "//java/com/google/androidenv/catch/sprite:Ball", + "//java/com/google/androidenv/catch/sprite:Paddle", + ], +) + +kt_android_library( + name = "RenderThread", + srcs = ["RenderThread.kt"], + deps = [ + ":GameLogic", + ], +) diff --git a/android_env/apps/java/com/google/androidenv/catch/GameLogic.kt b/android_env/apps/java/com/google/androidenv/catch/GameLogic.kt new file mode 100644 index 00000000..6ed0c839 --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/catch/GameLogic.kt @@ -0,0 +1,76 @@ +// Copyright 2025 DeepMind Technologies Limited. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.androidenv.catch + +import android.graphics.Canvas +import android.view.MotionEvent +import com.google.androidenv.catch.sprite.Background +import com.google.androidenv.catch.sprite.Ball +import com.google.androidenv.catch.sprite.LineSegment +import com.google.androidenv.catch.sprite.Paddle +import java.time.Duration +import java.time.Instant +import kotlin.random.Random + +/** The class that contains the game logic. */ +open class GameLogic( + // Expected number of frames per second. + fps: Int = 60, + // Pseudo random number generator. + private val rand: Random = Random.Default, + // Width and height of the game in pixels. + private val width: Int, + private val height: Int, + // UI objects in the game. + private var background: Background = Background(), + private var ball: Ball = Ball(maxX = width, maxY = height, rand = rand), + private var paddle: Paddle = Paddle(maxX = width, y = height), +) { + + private val sleepTime: Duration = Duration.ofMillis((1000.0 / fps).toLong()) + + /** Reinitializes the state of the game. */ + // Need to make this open to allow for testing. + open fun reset() { + this.ball.reset() + } + + /** Runs one "throw" of a [ball] that needs to be caught by the [paddle]. */ + // Need to make this open to allow for testing. + open fun run(): Boolean { + var lastTimestamp = Instant.now() + do { + Thread.sleep(sleepTime.toMillis()) + val now = Instant.now() + val interval = Duration.between(lastTimestamp, now) + lastTimestamp = now + ball.update(interval) + } while (!ball.isOutOfBounds()) + + return ball.intersects(LineSegment(paddle.topLeft(), paddle.topRight())) + } + + /** Processes a user event (e.g. a touchscreen event) and updates the [paddle] accordingly. */ + fun handleTouch(event: MotionEvent) { + paddle.x = event.x.toInt() + } + + /** Renders the game on [c]. */ + open fun render(c: Canvas) { + background.draw(c) + ball.draw(c) + paddle.draw(c) + } +} diff --git a/android_env/apps/java/com/google/androidenv/catch/GameLogicThread.kt b/android_env/apps/java/com/google/androidenv/catch/GameLogicThread.kt new file mode 100644 index 00000000..231d481b --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/catch/GameLogicThread.kt @@ -0,0 +1,45 @@ +// Copyright 2025 DeepMind Technologies Limited. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.androidenv.catch + +import android.util.Log + +/** A thread that continuously runs the game logic, resetting after each internal [run()]. */ +class GameLogicThread(private val game: GameLogic, private val loggingTag: String) : Thread() { + + /** Whether this thread should continuously run. */ + private var shouldRun: Boolean = true + /** A counter of game runs. */ + private var counter: Int = 0 + + /** + * Lets the current [run()] iteration complete then break exit this [Thread]. + * + * Notice that [shouldRun] cannot have a private getter with a public setter (please see + * https://youtrack.jetbrains.com/issue/KT-3110 for details), hence this public function. Also + * notice that we cannot call this function [stop()] since it would shadow [Thread.stop()]. + */ + public fun finish() { + shouldRun = false + } + + /** Continuously runs the [game] until [finish()] is called. */ + public override fun run() { + while (shouldRun) { + game.reset() + Log.i(loggingTag, "${counter++} - ${game.run()}") + } + } +} diff --git a/android_env/apps/java/com/google/androidenv/catch/MainActivity.kt b/android_env/apps/java/com/google/androidenv/catch/MainActivity.kt new file mode 100644 index 00000000..41eb3b25 --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/catch/MainActivity.kt @@ -0,0 +1,152 @@ +// Copyright 2025 DeepMind Technologies Limited. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.androidenv.catch + +import android.app.Activity +import android.content.Intent +import android.graphics.Color +import android.os.Bundle +import android.util.Log +import android.view.SurfaceHolder +import android.view.SurfaceView +import android.view.View +import android.view.Window +import com.google.androidenv.catch.sprite.Background +import com.google.androidenv.catch.sprite.Ball +import com.google.androidenv.catch.sprite.Paddle + +/** The activity that allows users to play the RL game of Catch. */ +class MainActivity() : Activity(), SurfaceHolder.Callback { + + private var surfaceView: SurfaceView? = null + private var renderThread: RenderThread? = null + private var gameLogicThread: GameLogicThread? = null + + private val fps: Int = 60 + private var gameCounter: Int = 0 + private var width: Int = -1 + private var height: Int = -1 + + private var extras: Bundle? = null + + // [Activity] overrides. + + /** Initializes the Android [View] and sets up callbacks. */ + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Log.i(TAG, "MainActivity::onCreate()") + requestWindowFeature(Window.FEATURE_NO_TITLE) + setContentView(R.layout.main) + val surface: SurfaceView? = findViewById(R.id.surfaceView) + if (surface == null) throw Exception("Could not create SurfaceView. Aborting...") + + surface.visibility = View.VISIBLE + surface.holder.addCallback(this) + surfaceView = surface + extras = intent?.extras + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + Log.i(TAG, "MainActivity::onNewIntent()") + extras = intent?.extras + startGame() + } + + // [SurfaceHolder.Callback] overrides. + + override fun surfaceCreated(holder: SurfaceHolder) { + Log.i(TAG, "MainActivity::surfaceCreated()") + renderThread = RenderThread(surfaceHolder = holder, fps = fps).also { it.start() } + } + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + Log.i(TAG, "MainActivity::surfaceChanged()") + this.width = width + this.height = height + startGame() + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + Log.i(TAG, "MainActivity::surfaceDestroyed()") + renderThread?.finish() + renderThread?.join() + gameLogicThread?.finish() + gameLogicThread?.join() + } + + private fun startGame() { + Log.i(TAG, "MainActivity::startGame()") + if (width <= 0 || height <= 0) { + Log.e(TAG, "MainActivity::startGame() - Width or height not initialized yet.") + return + } + val backgroundColor = Color.parseColor(extras?.getString("backgroundColor") ?: "BLACK") + val ballColor = Color.parseColor(extras?.getString("ballColor") ?: "WHITE") + val ballRadius = extras?.getFloat("ballRadius", 10.0f) ?: 10.0f + val ballSpeed = extras?.getFloat("ballSpeed", 0.2f) ?: 0.2f + val paddleColor = Color.parseColor(extras?.getString("paddleColor") ?: "WHITE") + val paddleWidth = extras?.getInt("paddleWidth", 80) ?: 80 + val paddleHeight = extras?.getInt("paddleHeight", 10) ?: 10 + Log.i(TAG, "MainActivity::startGame() - extras bundle: $extras") + val game = + GameLogic( + width = width, + height = height, + fps = fps, + background = Background(color = backgroundColor), + ball = + Ball( + maxX = width, + maxY = height, + color = ballColor, + radius = ballRadius, + speed = ballSpeed, + ), + paddle = + Paddle( + color = paddleColor, + width = paddleWidth, + height = paddleHeight, + maxX = width, + y = (height - paddleHeight / 2), + ), + ) + + // Stop the previous game logic thread if it's running. + gameLogicThread?.finish() + gameLogicThread?.join() + + // Create and start the new GameLogicThread, passing the game instance. + gameLogicThread = GameLogicThread(game, TAG).also { it.start() } + + // Pass the same game instance to the render thread. + renderThread?.game = game + + surfaceView?.setOnTouchListener( + // Suppress warning for ClickableViewAccessibility since click handling + // is not within an OnTouchListener. + @SuppressWarnings("ClickableViewAccessibility") + View.OnTouchListener { _, motionEvent -> + game.handleTouch(motionEvent) + true + } + ) + } + + companion object { + private const val TAG = "AndroidRLTask" + } +} diff --git a/android_env/apps/java/com/google/androidenv/catch/RenderThread.kt b/android_env/apps/java/com/google/androidenv/catch/RenderThread.kt new file mode 100644 index 00000000..51c9d347 --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/catch/RenderThread.kt @@ -0,0 +1,53 @@ +// Copyright 2025 DeepMind Technologies Limited. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.androidenv.catch + +import android.graphics.Canvas +import android.view.SurfaceHolder +import java.time.Duration + +/** A thread that continuously renders the game logic onto a surface. */ +class RenderThread(private val surfaceHolder: SurfaceHolder, private val fps: Int = 60) : Thread() { + + /** Whether this thread should continuously run. */ + private var shouldRun: Boolean = true + /** How long to sleep at each [run()] iteration. */ + private val sleepTime: Duration = Duration.ofMillis((1000.0 / fps).toLong()) + /** The class responsible for issuing rendering commands to the canvas. */ + var game: GameLogic? = null + + /** + * Runs the current game logic [run()] to completion. + * + * Notice that [shouldRun] cannot have a private getter with a public setter (please see + * https://youtrack.jetbrains.com/issue/KT-3110 for details), hence this public function. Also + * notice that we cannot call this function [stop()] since it would shadow [Thread.stop()]. + */ + public fun finish() { + shouldRun = false + } + + /** Continuously renders the [game] onto [surfaceHolder]. */ + public override fun run() { + while (shouldRun) { + if (surfaceHolder.surface?.isValid() ?: false) { + val c: Canvas = surfaceHolder.lockCanvas() + game?.render(c) + surfaceHolder.unlockCanvasAndPost(c) + } + Thread.sleep(sleepTime.toMillis()) + } + } +} diff --git a/android_env/apps/java/com/google/androidenv/catch/res/layout/main.xml b/android_env/apps/java/com/google/androidenv/catch/res/layout/main.xml new file mode 100644 index 00000000..f20aa6b4 --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/catch/res/layout/main.xml @@ -0,0 +1,27 @@ + + + + + + + diff --git a/android_env/apps/java/com/google/androidenv/catch/res/values/strings.xml b/android_env/apps/java/com/google/androidenv/catch/res/values/strings.xml new file mode 100644 index 00000000..7611b8c9 --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/catch/res/values/strings.xml @@ -0,0 +1,19 @@ + + + + + + Catch + diff --git a/android_env/apps/java/com/google/androidenv/catch/sprite/BUILD.bazel b/android_env/apps/java/com/google/androidenv/catch/sprite/BUILD.bazel new file mode 100644 index 00000000..b787297d --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/catch/sprite/BUILD.bazel @@ -0,0 +1,64 @@ +# Copyright 2025 DeepMind Technologies Limited. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Sprites for the app. + +load("@rules_kotlin//kotlin:android.bzl", "kt_android_library") + +package( + default_visibility = ["//java/com/google/androidenv/catch:catch_packages"], +) + +licenses(["notice"]) + +kt_android_library( + name = "Background", + srcs = ["Background.kt"], + deps = [":Sprite"], +) + +kt_android_library( + name = "Ball", + srcs = ["Ball.kt"], + deps = [ + ":LineSegment", + ":Point", + ":Sprite", + ], +) + +kt_android_library( + name = "LineSegment", + srcs = ["LineSegment.kt"], + deps = [":Point"], +) + +kt_android_library( + name = "Paddle", + srcs = ["Paddle.kt"], + deps = [ + ":Point", + ":Sprite", + ], +) + +kt_android_library( + name = "Point", + srcs = ["Point.kt"], +) + +kt_android_library( + name = "Sprite", + srcs = ["Sprite.kt"], +) diff --git a/android_env/apps/java/com/google/androidenv/catch/sprite/Background.kt b/android_env/apps/java/com/google/androidenv/catch/sprite/Background.kt new file mode 100644 index 00000000..a985b3e4 --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/catch/sprite/Background.kt @@ -0,0 +1,26 @@ +// Copyright 2025 DeepMind Technologies Limited. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.androidenv.catch.sprite + +import android.graphics.Canvas +import android.graphics.Color + +/** Represents the static background behind all objects. */ +open class Background(private val color: Int = Color.BLACK) : Sprite() { + /** Paints the canvas with the color given in the constructor. */ + override fun draw(c: Canvas) { + c.drawColor(color) + } +} diff --git a/android_env/apps/java/com/google/androidenv/catch/sprite/Ball.kt b/android_env/apps/java/com/google/androidenv/catch/sprite/Ball.kt new file mode 100644 index 00000000..049c34b6 --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/catch/sprite/Ball.kt @@ -0,0 +1,108 @@ +// Copyright 2025 DeepMind Technologies Limited. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.androidenv.catch.sprite + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import java.time.Duration +import kotlin.math.ceil +import kotlin.math.sqrt +import kotlin.random.Random + +/** Represents a ball that travels down in space with constant speed. */ +open class Ball( + private val maxX: Int, + private val maxY: Int, + private val color: Int = Color.WHITE, + private val radius: Float = 10.0f, + // `speed`'s unit is in pixels/ms. + private val speed: Float = 1.0f, + private val rand: Random = Random.Default, +) : Sprite() { + + // `x` and `y` represent the position of the center of this ball. + // + // Valid range [0, maxX]. 0==left, maxX==right. + private var x: Int = rand.nextInt(maxX) + // Valid range [0, maxY]. 0==top, maxY==bottom. + private var y: Int = ceil(radius).toInt() + + private val paint: Paint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = (this@Ball).color + } + + /** Returns `true` if this ball intersects the given line [segment]. */ + fun intersects(segment: LineSegment): Boolean { + + /** A vector with two components. */ + data class Vector2D(val u: Int, val v: Int) { + /** Returns the dot product between two 2D vectors. */ + fun dot(other: Vector2D): Int = u * other.u + v * other.v + } + + /** Returns the vector representing [p] minus [q]. */ + fun pointDiff(p: Point, q: Point): Vector2D = Vector2D(p.x - q.x, p.y - q.y) + + val direction = pointDiff(segment.p1, segment.p0) // p0 -> p1. + val centerToP = pointDiff(segment.p0, Point(x, y)) // Ball center -> p0. + + // The `(centerToP + m * direction)` function models all the points in the line segment where + // the independent variable `m` is a real number in [0,1]. Putting this function into the + // formula for the circle (x ^ 2 + y ^ 2 = radius ^ 2) gives a quadratic equation + // (am^2 + bm + c = 0) where: + // [a] = direction · direction + // [b] = 2 centerToP · direction + // [c] = centerToP · centerToP - radius ^ 2 + val a = direction.dot(direction) + val b = 2 * centerToP.dot(direction) + val c = centerToP.dot(centerToP) - radius * radius + + val delta = b * b - 4 * a * c + if (delta < 0) + return false // No real roots means the (infinite) line does not intersect the ball. + + val d = sqrt(delta) + val m1 = (-b - d) / (2 * a) + val m2 = (-b + d) / (2 * a) + + // If a root is in [0,1], the line segment intersects the circumference. + // If [m1] < 0 and [m2] > 1, the line segment is "within" the circle meaning the circle + // intersects the infinite line, but not the line segment. In this case, we consider that it + // touched the ball. + return (m1 >= 0 && m1 <= 1) || (m2 >= 0 && m2 <= 1) || (m1 < 0 && m2 > 1) + } + + /** Places the ball at the top of the screen at a random x-coordinate. */ + fun reset() { + x = rand.nextInt(maxX) + y = ceil(radius).toInt() + } + + /** Moves the ball down by [timeDeltaMs]. */ + open fun update(timeDelta: Duration) { + y += (speed * timeDelta.toMillis()).toInt() + } + + /** Returns whether the ball is over [maxY]. */ + fun isOutOfBounds(): Boolean = y + radius > maxY || y - radius < 0 + + /** Draws this ball in `c`. */ + override fun draw(c: Canvas) { + c.drawCircle(x.toFloat(), y.toFloat(), radius, paint) + } +} diff --git a/android_env/apps/java/com/google/androidenv/catch/sprite/LineSegment.kt b/android_env/apps/java/com/google/androidenv/catch/sprite/LineSegment.kt new file mode 100644 index 00000000..54807cb3 --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/catch/sprite/LineSegment.kt @@ -0,0 +1,18 @@ +// Copyright 2025 DeepMind Technologies Limited. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.androidenv.catch.sprite + +/** Represents a finite line segment in 2D connected by two points [p0] and [p1]. */ +data class LineSegment(val p0: Point, val p1: Point) diff --git a/android_env/apps/java/com/google/androidenv/catch/sprite/Paddle.kt b/android_env/apps/java/com/google/androidenv/catch/sprite/Paddle.kt new file mode 100644 index 00000000..fb57001a --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/catch/sprite/Paddle.kt @@ -0,0 +1,70 @@ +// Copyright 2025 DeepMind Technologies Limited. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.androidenv.catch.sprite + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Rect +import kotlin.ranges.coerceIn + +/** Represents a paddle to hit/catch a falling ball. */ +open class Paddle( + private val color: Int = Color.WHITE, + // Width and height in pixels. + private val width: Int = 80, + private val height: Int = 10, + // maxX is the maximum X value for the center of the paddle. + private val maxX: Int = 100, + // The vertical position of the center of this paddle in pixels. + val y: Int = 100, +) : Sprite() { + + // Memoize a few things to make [draw()] a bit faster. + private val halfH = height / 2 + private val halfW = width / 2 + private val paint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = (this@Paddle).color + } + + // The horizontal center of the paddle. + var x: Int = maxX / 2 // Start in the middle. + set(value) { + field = value.coerceIn(0, maxX) + } + + /** Returns the (x,y) coordinates of the top-left corner. */ + fun topLeft(): Point = Point(x - halfW, y - halfH) + + /** Returns the (x,y) coordinates of the top-right corner. */ + fun topRight(): Point = Point(x + halfW, y - halfH) + + fun move(deltaX: Int) { + x += deltaX + } + + override fun draw(c: Canvas) { + val rect = + Rect().apply { + bottom = y + halfH + top = y - halfH + left = x - halfW + right = x + halfW + } + c.drawRect(rect, paint) + } +} diff --git a/android_env/apps/java/com/google/androidenv/catch/sprite/Point.kt b/android_env/apps/java/com/google/androidenv/catch/sprite/Point.kt new file mode 100644 index 00000000..adb59443 --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/catch/sprite/Point.kt @@ -0,0 +1,18 @@ +// Copyright 2025 DeepMind Technologies Limited. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.androidenv.catch.sprite + +/** Represents a cartesian point in 2D. */ +data class Point(val x: Int, val y: Int) diff --git a/android_env/apps/java/com/google/androidenv/catch/sprite/Sprite.kt b/android_env/apps/java/com/google/androidenv/catch/sprite/Sprite.kt new file mode 100644 index 00000000..09520281 --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/catch/sprite/Sprite.kt @@ -0,0 +1,24 @@ +// Copyright 2025 DeepMind Technologies Limited. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.androidenv.catch.sprite + +import android.graphics.Canvas + +/** Represents something that can be drawn on the screen. */ +open class Sprite { + + /** Draws the Sprite in the given canvas. */ + open fun draw(c: Canvas) {} +} diff --git a/android_env/apps/javatests/com/google/androidenv/catch/AndroidManifest.xml b/android_env/apps/javatests/com/google/androidenv/catch/AndroidManifest.xml new file mode 100644 index 00000000..f173d841 --- /dev/null +++ b/android_env/apps/javatests/com/google/androidenv/catch/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + diff --git a/android_env/apps/javatests/com/google/androidenv/catch/BUILD.bazel b/android_env/apps/javatests/com/google/androidenv/catch/BUILD.bazel new file mode 100644 index 00000000..5843ae2d --- /dev/null +++ b/android_env/apps/javatests/com/google/androidenv/catch/BUILD.bazel @@ -0,0 +1,87 @@ +# Copyright 2025 DeepMind Technologies Limited. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Tests for the Android version of the RL Catch game. +load("@rules_kotlin//kotlin:android.bzl", "kt_android_local_test") +load("@rules_kotlin//kotlin:core.bzl", "kt_kotlinc_options") + +kt_kotlinc_options( + name = "kt_kotlinc_options", + jvm_target = "11", # Need to override default 1.8. + x_no_param_assertions = True, +) + +kt_android_local_test( + name = "GameLogicTest", + srcs = ["GameLogicTest.kt"], + kotlinc_opts = ":kt_kotlinc_options", + deps = [ + "//java/com/google/androidenv/catch:GameLogic", + "//java/com/google/androidenv/catch/sprite:Background", + "//java/com/google/androidenv/catch/sprite:Ball", + "//java/com/google/androidenv/catch/sprite:Paddle", + "@maven//:androidx_test_ext_junit", + "@maven//:androidx_test_runner", + "@maven//:com_google_truth_truth", + "@maven//:org_mockito_kotlin_mockito_kotlin", + "@maven//:org_robolectric_robolectric", + "@robolectric//bazel:android-all", + ], +) + +kt_android_local_test( + name = "GameLogicThreadTest", + srcs = ["GameLogicThreadTest.kt"], + kotlinc_opts = ":kt_kotlinc_options", + deps = [ + "//java/com/google/androidenv/catch:GameLogic", + "//java/com/google/androidenv/catch:GameLogicThread", + "@maven//:androidx_test_ext_junit", + "@maven//:com_google_truth_truth", + "@maven//:org_mockito_kotlin_mockito_kotlin", + "@maven//:org_robolectric_robolectric", + "@robolectric//bazel:android-all", + ], +) + +kt_android_local_test( + name = "MainActivityTest", + srcs = [ + "MainActivityTest.kt", + ], + kotlinc_opts = ":kt_kotlinc_options", + manifest = "AndroidManifest.xml", + deps = [ + "//java/com/google/androidenv/catch:MainActivity", + "@maven//:androidx_test_ext_junit", + "@maven//:junit_junit", + "@maven//:org_robolectric_robolectric", + "@robolectric//bazel:android-all", + ], +) + +kt_android_local_test( + name = "RenderThreadTest", + srcs = ["RenderThreadTest.kt"], + kotlinc_opts = ":kt_kotlinc_options", + deps = [ + "//java/com/google/androidenv/catch:GameLogic", + "//java/com/google/androidenv/catch:RenderThread", + "@maven//:androidx_test_ext_junit", + "@maven//:org_mockito_kotlin_mockito_kotlin", + "@maven//:org_mockito_mockito_core", + "@maven//:org_robolectric_robolectric", + "@robolectric//bazel:android-all", + ], +) diff --git a/android_env/apps/javatests/com/google/androidenv/catch/GameLogicTest.kt b/android_env/apps/javatests/com/google/androidenv/catch/GameLogicTest.kt new file mode 100644 index 00000000..a0a72b6b --- /dev/null +++ b/android_env/apps/javatests/com/google/androidenv/catch/GameLogicTest.kt @@ -0,0 +1,195 @@ +// Copyright 2025 DeepMind Technologies Limited. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.androidenv.catch + +import android.graphics.Canvas +import androidx.test.core.view.MotionEventBuilder +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.androidenv.catch.sprite.Background +import com.google.androidenv.catch.sprite.Ball +import com.google.androidenv.catch.sprite.Paddle +import com.google.common.truth.Truth.assertThat +import java.time.Duration +import java.time.Instant +import kotlin.random.Random +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.atLeast +import org.mockito.kotlin.atMost +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.times +import org.mockito.kotlin.verify + +@RunWith(AndroidJUnit4::class) +class GameLogicTest { + + @Test + fun run_ballIsMissed() { + // Arrange. + val width = 123 + val height = 33 + val mockRandom: Random = mock() { on { nextInt(any()) } doReturn 37 } + val game = + GameLogic( + rand = mockRandom, + width = width, + height = height, + ball = Ball(maxX = width, maxY = height, radius = 5.0f, rand = mockRandom), + paddle = Paddle(maxX = width, y = height, width = 3, height = 2), + ) + game.reset() + game.handleTouch( + MotionEventBuilder.newBuilder().setPointer(/* x= */ 12.0f, /* y= */ 31.0f).build() + ) + + // Act. + val outcome = game.run() // Ball falls at x==37, ev.x==12 so ball is missed. + + // Assert. + assertThat(outcome).isEqualTo(false) + } + + @Test + fun run_ballIsCaught() { + // Arrange. + val mockRandom: Random = mock() { on { nextInt(any()) } doReturn 53 } + val game = GameLogic(rand = mockRandom, width = 321, height = 47) + game.reset() + game.handleTouch( + MotionEventBuilder.newBuilder().setPointer(/* x= */ 53.0f, /* y= */ 43.0f).build() + ) + + // Act. + val outcome = game.run() // Ball falls at x==53, ev.x==53 so ball is caught. + + // Assert. + assertThat(outcome).isEqualTo(true) + } + + @Test + fun run_resetAllowsMultipleGamesToBePlayedWithASingleObjectAndDoesNotHang() { + // Arrange. + val mockRandom: Random = mock() + val game = GameLogic(width = 101, height = 59, rand = mockRandom) + + // Act. + repeat(17) { + game.reset() + val unused = game.run() // Ignore the outcome since we only care about run() terminating. + } + + // Assert. + // [rand.nextInt()] should be called once at construction and then 17 times for [reset()]. + verify(mockRandom, times(18)).nextInt(any()) + } + + @Test + fun run_inASeparateThread() { + // Arrange. + val mockRandom: Random = mock() { on { nextInt(any()) } doReturn 23 } + val game = GameLogic(rand = mockRandom, width = 321, height = 89) + game.reset() + game.handleTouch( + MotionEventBuilder.newBuilder().setPointer(/* x= */ 23.0f, /* y= */ 29.0f).build() + ) + var outcome: Boolean = false + + class MyThread(val g: GameLogic, var outcome: Boolean) : Thread() { + public override fun run() { + outcome = g.run() + } + } + val someThread = MyThread(game, outcome) + + // Act. + someThread.start() // Ball falls at x==23, ev.x==23 so ball is caught. + someThread.join() + + // Assert. + assertThat(outcome).isEqualTo(true) + } + + @Test + fun run_fpsLeadstoApproximatelyNumberOfElapsedTimeAndUpdateCalls() { + // Arrange. + val width = 123 + val height = 300 + val ball = spy(Ball(maxX = width, maxY = height, speed = 2.0f, radius = 1.0f)) + val game = GameLogic(fps = 100, width = width, height = height, ball = ball) + game.reset() + + // Act. + val start = Instant.now() + val unused = game.run() + val end = Instant.now() + + // Assert. + val elapsed = Duration.between(start, end) + // The ball should take around `height / speed = 150` milliseconds to reach the bottom. Due to + // timing non-determinism, we accept values between 100 and 200. + assertThat(elapsed.toMillis()).isAtLeast(100L) + assertThat(elapsed.toMillis()).isAtMost(200L) + // At fps==100, we expect [update()] to be called every `1000 / 100 = 10` milliseconds. We + // expect [elapsed] to be around 150ms (checked above) which should be around `150 / 10 = 15` + // calls, so to account for timing non-determinism we accept between 5 and 25 calls. + verify(ball, atLeast(5)).update(any()) + verify(ball, atMost(25)).update(any()) + } + + @Test + fun render_drawCanBeCalledMultipleTimesWithinASingleRun() { + // Arrange. + val width = 321 + val height = 89 + val mockCanvas: Canvas = mock() + val mockRandom: Random = mock() { on { nextInt(any()) } doReturn 23 } + val background = spy(Background()) + val paddle = spy(Paddle()) + val ball = spy(Ball(maxX = width, maxY = height)) + val game = + GameLogic( + rand = mockRandom, + width = width, + height = height, + background = background, + ball = ball, + paddle = paddle, + ) + game.reset() + game.handleTouch( + MotionEventBuilder.newBuilder().setPointer(/* x= */ 23.0f, /* y= */ 29.0f).build() + ) + + class MyThread(val g: GameLogic) : Thread() { + public override fun run() { + val unused = g.run() + } + } + val someThread = MyThread(game) + + // Act. + someThread.start() + repeat(11) { game.render(mockCanvas) } + someThread.join() + + // Assert. + verify(background, times(11)).draw(mockCanvas) + verify(ball, times(11)).draw(mockCanvas) + verify(paddle, times(11)).draw(mockCanvas) + } +} diff --git a/android_env/apps/javatests/com/google/androidenv/catch/GameLogicThreadTest.kt b/android_env/apps/javatests/com/google/androidenv/catch/GameLogicThreadTest.kt new file mode 100644 index 00000000..11d7fe6a --- /dev/null +++ b/android_env/apps/javatests/com/google/androidenv/catch/GameLogicThreadTest.kt @@ -0,0 +1,72 @@ +// Copyright 2025 DeepMind Technologies Limited. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.androidenv.catch + +import android.util.Log +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.robolectric.junit.rules.ExpectedLogMessagesRule + +@RunWith(AndroidJUnit4::class) +class GameLogicThreadTest { + + // Rule to assert log messages, taken as a reference from MainActivityTest.kt + @get:Rule val expectedLogMessagesRule = ExpectedLogMessagesRule() + + private val mockGame: GameLogic = mock() + private val testTag = "TestAndroidRLTask" + + @Test + fun run_iteratesGameAndLogs() { + // Arrange + val gameLogicThread = GameLogicThread(mockGame, testTag) + + // Act + gameLogicThread.start() + Thread.sleep(100) // Allow time for the thread to execute at least once. + gameLogicThread.finish() + gameLogicThread.join() // Wait for the thread to terminate. + + // Assert + // Verify that the game's core methods were called at least once. + verify(mockGame, atLeastOnce()).reset() + verify(mockGame, atLeastOnce()).run() + // Expect the log message from the run() loop. + // The mock 'game.run()' returns false by default. + expectedLogMessagesRule.expectLogMessage(Log.INFO, testTag, "0 - false") + } + + @Test + fun finish_stopsTheThread() { + // Arrange + val gameLogicThread = GameLogicThread(mockGame, testTag) + + // Act + gameLogicThread.start() + // Let it run for a moment before stopping it. + Thread.sleep(50) + gameLogicThread.finish() + gameLogicThread.join() + + // Assert + assertThat(gameLogicThread.isAlive).isFalse() + } +} diff --git a/android_env/apps/javatests/com/google/androidenv/catch/MainActivityTest.kt b/android_env/apps/javatests/com/google/androidenv/catch/MainActivityTest.kt new file mode 100644 index 00000000..242e1e4e --- /dev/null +++ b/android_env/apps/javatests/com/google/androidenv/catch/MainActivityTest.kt @@ -0,0 +1,85 @@ +// Copyright 2025 DeepMind Technologies Limited. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.androidenv.catch + +import android.content.Intent +import android.util.Log +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import java.lang.reflect.Method +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.junit.rules.ExpectedLogMessagesRule + +@RunWith(AndroidJUnit4::class) +class MainActivityTest { + @get:Rule(order = 0) val activityScenarioRule = ActivityScenarioRule(MainActivity::class.java) + @get:Rule(order = 1) val expectedLogMessagesRule = ExpectedLogMessagesRule() + + @Before + fun setUp() { + expectedLogMessagesRule.expectLogMessage(Log.INFO, TAG, "MainActivity::onCreate()") + } + + @Test + fun surfaceChanged_logsStartsGame() { + activityScenarioRule.scenario.onActivity { activity -> + // Arrange. + val surfaceView = activity.findViewById(R.id.surfaceView) + val surfaceHolder = surfaceView.holder + + // Act - Trigger the surfaceChanged callback with positive width and height. + activity.surfaceChanged(surfaceHolder, 0, 100, 200) + + // Assert. + expectedLogMessagesRule.expectLogMessage(Log.INFO, TAG, "MainActivity::surfaceChanged()") + expectedLogMessagesRule.expectLogMessage(Log.INFO, TAG, "MainActivity::startGame()") + } + } + + @Test + fun onNewIntent_logsStartsGame_errorsOnUninitializedWidthOrHeight() { + // Arrange. + val newIntent = Intent() + // Find the onNewIntent method using reflection + val onNewIntentMethod: Method = + MainActivity::class.java.getDeclaredMethod("onNewIntent", Intent::class.java) + // Enable access to protected method + onNewIntentMethod.isAccessible = true + + activityScenarioRule.scenario.onActivity { activity -> + // Act - Invoke the onNewIntent method using reflection. + onNewIntentMethod.invoke(activity, newIntent) + + // Assert. + expectedLogMessagesRule.expectLogMessage(Log.INFO, TAG, "MainActivity::onNewIntent()") + expectedLogMessagesRule.expectLogMessage(Log.INFO, TAG, "MainActivity::startGame()") + // In this test case where we don't call surfaceChanged(), default width and height + // are -1 and should trigger this error to prevent Ball from initializing + // with invalid negative values, since nextInt() expects a positive number. + expectedLogMessagesRule.expectLogMessage( + Log.ERROR, + TAG, + "MainActivity::startGame() - Width or height not initialized yet.", + ) + } + } + + companion object { + private const val TAG = "AndroidRLTask" + } +} diff --git a/android_env/apps/javatests/com/google/androidenv/catch/RenderThreadTest.kt b/android_env/apps/javatests/com/google/androidenv/catch/RenderThreadTest.kt new file mode 100644 index 00000000..b4f0aa63 --- /dev/null +++ b/android_env/apps/javatests/com/google/androidenv/catch/RenderThreadTest.kt @@ -0,0 +1,136 @@ +// Copyright 2025 DeepMind Technologies Limited. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.androidenv.catch + +import android.graphics.Canvas +import android.view.Surface +import android.view.SurfaceHolder +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.verifyNoInteractions +import org.mockito.kotlin.any +import org.mockito.kotlin.atLeast +import org.mockito.kotlin.atMost +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify + +@RunWith(AndroidJUnit4::class) +class RenderThreadTest { + + @Test + fun run_finishBeforeStartResultsInNoRendering() { + // Arrange. + val surfaceHolder: SurfaceHolder = mock() + val renderThread = RenderThread(surfaceHolder = surfaceHolder, fps = 1000) + val game: GameLogic = mock() + renderThread.game = game + + // Act. + renderThread.finish() + renderThread.start() + + // Assert. + verifyNoInteractions(game) + verifyNoInteractions(surfaceHolder) + } + + @Test + fun run_startResultsInSomeRendering() { + // Arrange. + val canvas: Canvas = mock() + val surface: Surface = mock() { on { isValid() } doReturn true } + val surfaceHolder: SurfaceHolder = + mock() { + on { getSurface() } doReturn surface + on { lockCanvas() } doReturn canvas + } + val renderThread = RenderThread(surfaceHolder = surfaceHolder, fps = 1000) + val game: GameLogic = mock() + renderThread.game = game + + // Act. + renderThread.start() + Thread.sleep(/* millis= */ 500) // Sleep for at least one loop iteration. + renderThread.finish() + + // Assert. + verify(surfaceHolder, atLeast(1)).surface + verify(surfaceHolder, atLeast(1)).lockCanvas() + verify(surfaceHolder, atLeast(1)).unlockCanvasAndPost(any()) + verify(game, atLeast(1)).render(canvas) + } + + @Test + fun run_finishStopsRendering() { + // Arrange. + val canvas: Canvas = mock() + val surface: Surface = mock() { on { isValid() } doReturn true } + val surfaceHolder: SurfaceHolder = + mock() { + on { getSurface() } doReturn surface + on { lockCanvas() } doReturn canvas + } + val renderThread = RenderThread(surfaceHolder = surfaceHolder, fps = 20) + val game: GameLogic = mock() + renderThread.game = game + + // Act. + renderThread.start() + Thread.sleep(/* millis= */ 500) // Sleep for around 10 iterations + renderThread.finish() + Thread.sleep(/* millis= */ 500) // Sleep some more to ensure nothing runs after. + + // Assert. + verify(surfaceHolder, atLeast(1)).surface + verify(surfaceHolder, atLeast(1)).lockCanvas() + verify(surfaceHolder, atLeast(1)).unlockCanvasAndPost(any()) + // We expect [game.render()] to be executed for around 500 / (1000 / 20 = 50) = 10 times. To + // allow for some timing non-determinism we allow it to execute up to 15 times, but not more + // than that since [renderThread.finish()] should stop the thread from calling it. + verify(game, atLeast(1)).render(canvas) + verify(game, atMost(15)).render(canvas) + } + + @Test + fun run_expectedFramesPerSecond() { + // Arrange. + val canvas: Canvas = mock() + val surface: Surface = mock() { on { isValid() } doReturn true } + val surfaceHolder: SurfaceHolder = + mock() { + on { getSurface() } doReturn surface + on { lockCanvas() } doReturn canvas + } + val renderThread = RenderThread(surfaceHolder = surfaceHolder, fps = 5) + val game: GameLogic = mock() + renderThread.game = game + + // Act. + renderThread.start() + Thread.sleep(/* millis= */ 2000) // Sleep for around 10 loop iterations. + renderThread.finish() + + // Assert. + verify(surfaceHolder, atLeast(1)).surface + verify(surfaceHolder, atLeast(1)).lockCanvas() + verify(surfaceHolder, atLeast(1)).unlockCanvasAndPost(any()) + // We expect [game.render()] to be called around 2000ms / 5fps = 10 times but to account for + // timing non-determinism we allow ±4 iterations. + verify(game, atLeast(6)).render(canvas) + verify(game, atMost(14)).render(canvas) + } +} diff --git a/android_env/apps/javatests/com/google/androidenv/catch/sprite/BUILD.bazel b/android_env/apps/javatests/com/google/androidenv/catch/sprite/BUILD.bazel new file mode 100644 index 00000000..8953d84c --- /dev/null +++ b/android_env/apps/javatests/com/google/androidenv/catch/sprite/BUILD.bazel @@ -0,0 +1,82 @@ +# Copyright 2025 DeepMind Technologies Limited. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Unit tests for Sprites in Catch. +load("@rules_kotlin//kotlin:android.bzl", "kt_android_local_test") +load("@rules_kotlin//kotlin:core.bzl", "kt_kotlinc_options") + +kt_kotlinc_options( + name = "kt_kotlinc_options", + jvm_target = "11", # Need to override default 1.8. + x_no_param_assertions = True, +) + +kt_android_local_test( + name = "BackgroundTest", + srcs = ["BackgroundTest.kt"], + kotlinc_opts = ":kt_kotlinc_options", + deps = [ + "//java/com/google/androidenv/catch/sprite:Background", + "@maven//:com_google_guava_guava", + "@maven//:com_google_testparameterinjector_test_parameter_injector", + "@maven//:org_mockito_kotlin_mockito_kotlin", + "@maven//:org_yaml_snakeyaml", + ], +) + +kt_android_local_test( + name = "BallTest", + srcs = ["BallTest.kt"], + kotlinc_opts = ":kt_kotlinc_options", + tags = ["robolectric"], + deps = [ + "//java/com/google/androidenv/catch/sprite:Ball", + "//java/com/google/androidenv/catch/sprite:LineSegment", + "//java/com/google/androidenv/catch/sprite:Point", + "@maven//:androidx_test_ext_junit", + "@maven//:com_google_guava_guava", + "@maven//:com_google_truth_truth", + "@maven//:org_mockito_kotlin_mockito_kotlin", + "@maven//:org_robolectric_robolectric", + "@robolectric//bazel:android-all", + ], +) + +kt_android_local_test( + name = "PaddleTest", + srcs = ["PaddleTest.kt"], + kotlinc_opts = ":kt_kotlinc_options", + tags = ["robolectric"], + deps = [ + "//java/com/google/androidenv/catch/sprite:Paddle", + "//java/com/google/androidenv/catch/sprite:Point", + "@maven//:androidx_test_ext_junit", + "@maven//:com_google_guava_guava", + "@maven//:com_google_truth_truth", + "@maven//:org_mockito_kotlin_mockito_kotlin", + "@maven//:org_robolectric_robolectric", + "@robolectric//bazel:android-all", + ], +) + +kt_android_local_test( + name = "SpriteTest", + srcs = ["SpriteTest.kt"], + kotlinc_opts = ":kt_kotlinc_options", + deps = [ + "//java/com/google/androidenv/catch/sprite:Sprite", + "@maven//:org_mockito_kotlin_mockito_kotlin", + "@maven//:org_mockito_mockito_core", + ], +) diff --git a/android_env/apps/javatests/com/google/androidenv/catch/sprite/BackgroundTest.kt b/android_env/apps/javatests/com/google/androidenv/catch/sprite/BackgroundTest.kt new file mode 100644 index 00000000..01f468d9 --- /dev/null +++ b/android_env/apps/javatests/com/google/androidenv/catch/sprite/BackgroundTest.kt @@ -0,0 +1,55 @@ +// Copyright 2025 DeepMind Technologies Limited. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.androidenv.catch.sprite + +import android.graphics.Canvas +import android.graphics.Color +import com.google.testing.junit.testparameterinjector.TestParameter +import com.google.testing.junit.testparameterinjector.TestParameterInjector +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify + +@RunWith(TestParameterInjector::class) +class BackgroundTest { + + @Test + fun draw_defaultConstructorIsBlack() { + // Arrange. + val mockCanvas: Canvas = mock() + val background: Background = Background() + + // Act. + background.draw(mockCanvas) + + // Assert. + verify(mockCanvas, times(1)).drawColor(Color.BLACK) + } + + @Test + fun draw_customColors(@TestParameter("0", "255", "13579", "2468", "12384173") colorInt: Int) { + // Arrange. + val mockCanvas: Canvas = mock() + val background: Background = Background(color = colorInt) + + // Act. + background.draw(mockCanvas) + + // Assert. + verify(mockCanvas, times(1)).drawColor(colorInt) + } +} diff --git a/android_env/apps/javatests/com/google/androidenv/catch/sprite/BallTest.kt b/android_env/apps/javatests/com/google/androidenv/catch/sprite/BallTest.kt new file mode 100644 index 00000000..c1e93a98 --- /dev/null +++ b/android_env/apps/javatests/com/google/androidenv/catch/sprite/BallTest.kt @@ -0,0 +1,387 @@ +// Copyright 2025 DeepMind Technologies Limited. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.androidenv.catch.sprite + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import java.time.Duration +import kotlin.random.Random +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Suite +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.robolectric.ParameterizedRobolectricTestRunner + +@RunWith(Suite::class) +@Suite.SuiteClasses( + BallTest.UpdateAndResetTests::class, + BallTest.ColorIntTest::class, + BallTest.CheckBoundsTest::class, + BallTest.IntersectsTest::class, +) +class BallTest { + + @RunWith(AndroidJUnit4::class) + class UpdateAndResetTests() { + @Test + fun isOutOfBounds_initialState_isFalse() { + // Arrange. + val mockRandom: Random = mock() { on { nextInt(any()) } doReturn 50 } // Horizontal middle. + with(Ball(maxX = 100, maxY = 100, radius = 3.0f, speed = 1.0f, rand = mockRandom)) { + assertThat(isOutOfBounds()).isEqualTo(false) + } + } + + @Test + fun isOutOfBounds_initialState_isTrueIfRadiusExceedsMaxY() { + // Arrange. + val mockRandom: Random = mock() { on { nextInt(any()) } doReturn 50 } // Horizontal middle. + with(Ball(maxX = 100, maxY = 10, radius = 11.0f, speed = 1.0f, rand = mockRandom)) { + assertThat(isOutOfBounds()).isEqualTo(true) + } + } + + @Test + fun isOutOfBounds_initialState_isFalseIfRadiusExceedsOnlyMaxX() { + // Arrange. + val mockRandom: Random = mock() { on { nextInt(any()) } doReturn 50 } // Horizontal middle. + with(Ball(maxX = 10, maxY = 100, radius = 11.0f, speed = 1.0f, rand = mockRandom)) { + assertThat(isOutOfBounds()).isEqualTo(false) + } + } + + @Test + fun update_zeroDurationDoesNotMove_withinBounds() { + // Arrange. + val mockRandom: Random = mock() { on { nextInt(any()) } doReturn 50 } // Horizontal middle. + with(Ball(maxX = 100, maxY = 100, radius = 3.0f, speed = 1.0f, rand = mockRandom)) { + // Act. + update(Duration.ofMillis(0)) // The ball should not move. + + // Assert. + assertThat(isOutOfBounds()).isEqualTo(false) // It should still be within the bounds. + } + } + + @Test + fun update_zeroDurationDoesNotMove_outOfBounds() { + // Arrange. + val mockRandom: Random = mock() { on { nextInt(any()) } doReturn 50 } // Horizontal middle. + with(Ball(maxX = 100, maxY = 100, radius = 3.0f, speed = 1.0f, rand = mockRandom)) { + update(Duration.ofMillis(110)) // Place the ball out of bounds. + assertThat(isOutOfBounds()).isEqualTo(true) + + // Act. + update(Duration.ofMillis(0)) // The ball should not move. + + // Assert. + assertThat(isOutOfBounds()).isEqualTo(true) // It should still be out of bounds. + } + } + + @Test + fun update_negativeDurationsMovesUp() { + // Arrange. + val mockRandom: Random = mock() { on { nextInt(any()) } doReturn 50 } // Horizontal middle. + with(Ball(maxX = 100, maxY = 100, radius = 3.0f, speed = 1.0f, rand = mockRandom)) { + update(Duration.ofMillis(30)) // Move the ball down 30 pixels. + assertThat(isOutOfBounds()).isEqualTo(false) + + // Act. + update(Duration.ofMillis(-50)) // Move the ball _up_ 50 pixels. + + // Assert. + assertThat(isOutOfBounds()).isEqualTo(true) // Now it should be out-of-bounds. + } + } + + @Test + fun update_singleThrow() { + // Ensures that a complete throw of a ball with radius==3.0f and maxY=100 behaves as expected. + // [isOutOfBounds()] should return [false] for the first (100-3.0f-3.0f)=94 [update()] calls, + // but [true] afterwards. + + // Arrange. + val mockRandom: Random = mock() { on { nextInt(any()) } doReturn 50 } // Horizontal middle. + with(Ball(maxX = 100, maxY = 100, radius = 3.0f, speed = 1.0f, rand = mockRandom)) { + // Act. + repeat(94) { + update(Duration.ofMillis(1)) + assertThat(isOutOfBounds()).isEqualTo(false) + } + update(Duration.ofMillis(1)) + + // Assert. + assertThat(isOutOfBounds()).isEqualTo(true) + } + } + + @Test + fun intersects_afterUpdate() { + // Arrange. + val mockRandom: Random = mock() { on { nextInt(any()) } doReturn 50 } // Horizontal middle. + + // Act & Assert. + with(Ball(maxX = 100, maxY = 100, radius = 3.0f, speed = 1.0f, rand = mockRandom)) { + assertThat(intersects(LineSegment(Point(40, 0), Point(60, 0)))).isEqualTo(true) + update(Duration.ofMillis(1)) + assertThat(intersects(LineSegment(Point(40, 0), Point(60, 0)))).isEqualTo(false) + } + } + + @Test + fun reset_intersectsInitialPositionShouldBeTrue() { + // Arrange. + val mockRandom: Random = mock() { on { nextInt(any()) } doReturn 50 } // Horizontal middle. + + with(Ball(maxX = 100, maxY = 100, radius = 3.0f, speed = 1.0f, rand = mockRandom)) { + // Act. + assertThat(intersects(LineSegment(Point(40, 0), Point(60, 0)))).isEqualTo(true) + + update(Duration.ofMillis(1)) // Move the ball 1 pixels down. + assertThat(intersects(LineSegment(Point(40, 0), Point(60, 0)))) + .isEqualTo(false) // Segment is now outside of the ball. + + reset() // Resetting should move the ball up again. + + // Assert. + assertThat(intersects(LineSegment(Point(40, 0), Point(60, 0)))) + .isEqualTo(true) // Segment is now inside of the ball. + } + } + + @Test + fun reset_differentInitialXCoordinates() { + // Arrange. + val ball: Ball = Ball(maxX = 100, maxY = 100, radius = 3.0f) + + // Act. + var pointInside: Boolean = false + var pointOutside: Boolean = false + while (!pointInside || !pointOutside) { + if (ball.intersects(LineSegment(Point(45, 0), Point(55, 0)))) { + pointInside = true + } else { + pointOutside = true + } + ball.reset() // Sample a new initial position for the ball. + } + + // Assert. + // Eventually after many initial positions the ball should satisfy both conditions. + assertThat(pointInside).isEqualTo(true) + assertThat(pointOutside).isEqualTo(true) + } + } + + @RunWith(ParameterizedRobolectricTestRunner::class) + class ColorIntTest(private val c: Int) { + + @Test + fun draw_customBallColors() { + // Arrange. + val mockRandom: Random = mock() { on { nextInt(any()) } doReturn 37 } + val mockCanvas: Canvas = mock() + val paintCaptor = argumentCaptor() + val ball: Ball = Ball(maxX = 50, maxY = 80, radius = 1.23f, color = c, rand = mockRandom) + + // Act. + ball.draw(mockCanvas) + + // Assert. + verify(mockCanvas).drawCircle(eq(37.0f), eq(2.0f), eq(1.23f), paintCaptor.capture()) + with(paintCaptor.lastValue) { + assertThat(color).isEqualTo(c) + assertThat(style).isEqualTo(Paint.Style.FILL) + } + } + + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "color = {0}") + fun parameters() = listOf(0, 255, -1, 13579, 2468, 12384173, Color.WHITE, Color.BLUE) + } + } + + @RunWith(ParameterizedRobolectricTestRunner::class) + class CheckBoundsTest(private val p: ParamPack) { + + @Test + fun intersects_checkBounds() { + // Arrange. + val mockRandom: Random = + mock() { on { nextInt(any()) } doReturn p.maxX / 2 } // Horizontal middle. + + // Act. + val ball: Ball = Ball(maxX = p.maxX, maxY = p.maxY, radius = p.radius, rand = mockRandom) + + // Assert. + assertThat(ball.intersects(LineSegment(Point(p.x - 1, p.y), Point(p.x + 1, p.y)))) + .isEqualTo(p.expected) + } + + data class ParamPack( + val maxX: Int, + val maxY: Int, + val radius: Float, + val x: Int, + val y: Int, + val expected: Boolean, + ) + + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "param = {0}") + fun parameters() = + listOf( + ParamPack( + maxX = 100, + maxY = 100, + radius = 10.0f, + x = 0, + y = 0, + expected = false, + ), // Ball to the right of `x`. + ParamPack( + maxX = 100, + maxY = 100, + radius = 10.0f, + x = 39, + y = 0, + expected = false, + ), // Ball to the right of `x`. + ParamPack( + maxX = 100, + maxY = 100, + radius = 10.0f, + x = 40, + y = 10, + expected = true, + ), // Ball contains `x`. + ParamPack( + maxX = 100, + maxY = 100, + radius = 10.0f, + x = 50, + y = 0, + expected = true, + ), // Ball contains `x`. + ParamPack( + maxX = 100, + maxY = 100, + radius = 10.0f, + x = 60, + y = 10, + expected = true, + ), // Ball contains `x`. + ParamPack( + maxX = 100, + maxY = 100, + radius = 10.0f, + x = 61, + y = 0, + expected = false, + ), // Ball to the left of `x`. + ParamPack( + maxX = 100, + maxY = 100, + radius = 10.0f, + x = 100, + y = 0, + expected = false, + ), // Ball to the left of `x`. + ParamPack( + maxX = 100, + maxY = 100, + radius = 10.0f, + x = 50, + y = 21, + expected = false, + ), // Ball above `y`. + ) + } + } + + @RunWith(ParameterizedRobolectricTestRunner::class) + class IntersectsTest(private val p: ParamPack) { + + @Test + fun intersects_ballAtx50y10radius10() { + // Arrange. + val mockRandom: Random = mock() { on { nextInt(any()) } doReturn 50 } // Horizontal middle. + + // Act. + val ball: Ball = Ball(maxX = 100, maxY = 100, radius = 10.0f, rand = mockRandom) + + // Assert. + assertThat(ball.intersects(p.segment)).isEqualTo(p.expected) + } + + data class ParamPack(val segment: LineSegment, val expected: Boolean) + + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "param = {0}") + fun parameters() = + listOf( + ParamPack( + segment = LineSegment(Point(50, 10), Point(80, 40)), + expected = true, + ), // Segment that starts at the center of the ball so it should always intersect. + ParamPack( + segment = LineSegment(Point(49, 0), Point(51, 0)), + expected = true, + ), // Tangential segment that touches the bottom of the ball. + ParamPack( + segment = LineSegment(Point(40, 5), Point(65, 7)), + expected = true, + ), // Segment longer than diameter, touching the circumference twice. + ParamPack( + segment = LineSegment(Point(42, 2), Point(58, 1)), + expected = true, + ), // Segment shorter than diameter, touching the circumference twice. + ParamPack( + segment = LineSegment(Point(44, 4), Point(54, 3)), + expected = true, + ), // Segment shorter than diameter, fully inside the circle, not touching the + // circumference. + ParamPack( + segment = LineSegment(Point(35, 4), Point(54, 3)), + expected = true, + ), // Segment that touches the circumference once "from the left". + ParamPack( + segment = LineSegment(Point(54, 7), Point(67, 13)), + expected = true, + ), // Segment that touches the circumference once "from the right". + ParamPack( + segment = LineSegment(Point(36, 7), Point(45, 0)), + expected = false, + ), // Segment "to the left of the ball". No intersection. + ParamPack( + segment = LineSegment(Point(58, -3), Point(60, 3)), + expected = false, + ), // Segment "to the right of the ball". No intersection. + ) + } + } +} diff --git a/android_env/apps/javatests/com/google/androidenv/catch/sprite/PaddleTest.kt b/android_env/apps/javatests/com/google/androidenv/catch/sprite/PaddleTest.kt new file mode 100644 index 00000000..3ebb7d23 --- /dev/null +++ b/android_env/apps/javatests/com/google/androidenv/catch/sprite/PaddleTest.kt @@ -0,0 +1,184 @@ +// Copyright 2025 DeepMind Technologies Limited. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.androidenv.catch.sprite + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Rect +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Suite +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.robolectric.ParameterizedRobolectricTestRunner + +@RunWith(Suite::class) +@Suite.SuiteClasses( + PaddleTest.ConstructorTests::class, + PaddleTest.MoveTests::class, + PaddleTest.XSetterTests::class, + PaddleTest.DrawTests::class, +) +class PaddleTest { + + @RunWith(AndroidJUnit4::class) + class ConstructorTests() { + + @Test + fun x_initialValueShouldBeAtCenter() { + with(Paddle(maxX = 30)) { assertThat(x).isEqualTo(15) } + with(Paddle(maxX = 31)) { assertThat(x).isEqualTo(15) } + } + + @Test + fun topLeft_correspondsToGivenValues() { + with(Paddle(width = 10, height = 6, maxX = 40, y = 33)) { + assertThat(topLeft()).isEqualTo(Point(x = 15, y = 30)) + } + } + + @Test + fun topRight_correspondsToGivenValues() { + with(Paddle(width = 10, height = 6, maxX = 40, y = 33)) { + assertThat(topRight()).isEqualTo(Point(x = 25, y = 30)) + } + } + } + + @RunWith(ParameterizedRobolectricTestRunner::class) + class MoveTests(private val p: ParamPack) { + + @Test + fun move_expectedDestination() { + // Arrange. + with(Paddle(maxX = 50)) { + // Act. + move(deltaX = p.displacement) + + // Assert. + assertThat(x).isEqualTo(p.expectedX) + } + } + + data class ParamPack(val displacement: Int, val expectedX: Int) + + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "param = {0}") + fun parameters() = + listOf( + // Initial position is x==25. + ParamPack(displacement = 10, expectedX = 35), + ParamPack(displacement = -10, expectedX = 15), + ParamPack(displacement = 0, expectedX = 25), + // Going beyond the left and right walls should clamp the values to 0 and 50. + ParamPack(displacement = -26, expectedX = 0), + ParamPack(displacement = 26, expectedX = 50), + ) + } + } + + @RunWith(ParameterizedRobolectricTestRunner::class) + class XSetterTests(private val p: ParamPack) { + + @Test + fun xSetter_expectedDestination() { + // Arrange. + with(Paddle(maxX = 50)) { + // Act. + x = p.target + + // Assert. + assertThat(x).isEqualTo(p.expectedX) + } + } + + data class ParamPack(val target: Int, val expectedX: Int) + + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "param = {0}") + fun parameters() = + listOf( + // Initial position is x==25. + ParamPack(target = 0, expectedX = 0), + ParamPack(target = 15, expectedX = 15), + ParamPack(target = 25, expectedX = 25), + ParamPack(target = 35, expectedX = 35), + ParamPack(target = 50, expectedX = 50), + // Going beyond the left and right walls should clamp the values to 0 and 50. + ParamPack(target = -1, expectedX = 0), + ParamPack(target = 51, expectedX = 50), + ) + } + } + + @RunWith(AndroidJUnit4::class) + class DrawTests() { + + @Test + fun draw_initialPosition() { + // Arrange. + val mockCanvas: Canvas = mock() + val rectCaptor = argumentCaptor() + val paintCaptor = argumentCaptor() + with(Paddle(color = Color.RED, width = 100, height = 20, maxX = 300, y = 400)) { + // Act. + draw(mockCanvas) + + // Assert. + assertThat(x).isEqualTo(150) + verify(mockCanvas).drawRect(rectCaptor.capture(), paintCaptor.capture()) + with(rectCaptor.lastValue) { + assertThat(bottom).isEqualTo(400 + 10) + assertThat(top).isEqualTo(400 - 10) + assertThat(left).isEqualTo(150 - 50) + assertThat(right).isEqualTo(150 + 50) + } + } + } + + @Test + fun draw_afterMove() { + // Arrange. + val mockCanvas: Canvas = mock() + val rectCaptor = argumentCaptor() + val paintCaptor = argumentCaptor() + with(Paddle(color = Color.RED, width = 100, height = 20, maxX = 300, y = 400)) { + // Act. + move(50) + draw(mockCanvas) + + // Assert. + assertThat(x).isEqualTo(200) + verify(mockCanvas).drawRect(rectCaptor.capture(), paintCaptor.capture()) + with(rectCaptor.lastValue) { + assertThat(bottom).isEqualTo(400 + 10) + assertThat(top).isEqualTo(400 - 10) + assertThat(left).isEqualTo(200 - 50) + assertThat(right).isEqualTo(200 + 50) + } + with(paintCaptor.lastValue) { + assertThat(color).isEqualTo(Color.RED) + assertThat(style).isEqualTo(Paint.Style.FILL) + } + } + } + } +} diff --git a/android_env/apps/javatests/com/google/androidenv/catch/sprite/SpriteTest.kt b/android_env/apps/javatests/com/google/androidenv/catch/sprite/SpriteTest.kt new file mode 100644 index 00000000..970fb76a --- /dev/null +++ b/android_env/apps/javatests/com/google/androidenv/catch/sprite/SpriteTest.kt @@ -0,0 +1,55 @@ +// Copyright 2025 DeepMind Technologies Limited. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.androidenv.catch.sprite + +import android.graphics.Canvas +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.mockito.Mockito.verifyNoInteractions +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify + +/** Trivial tests to ensure the types in the API are correct. */ +@RunWith(JUnit4::class) +class SpriteTest { + + @Test + fun defaultImplementationDoesNothing() { + // Arrange. + val mockCanvas: Canvas = mock() + val sprite = Sprite() + + // Act. + sprite.draw(mockCanvas) + + // Assert. + verifyNoInteractions(mockCanvas) // No methods should be called on the canvas. + } + + @Test + fun draw_argumentsAreForwarded() { + // Arrange. + val mockSprite: Sprite = mock() + val mockCanvas: Canvas = mock() + + // Act. + mockSprite.draw(mockCanvas) + + // Assert. + verify(mockSprite, times(1)).draw(mockCanvas) + } +}