From 20357c4c29b827f8de9a90248733729b2d35a836 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Tue, 18 Aug 2026 01:02:38 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BF=D0=B0=D1=83=D0=B7=D1=83=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=81=D0=BB=D0=B5=20=D0=B2=D0=BE=D0=B7=D0=B2=D1=80=D0=B0=D1=82?= =?UTF-8?q?=D0=B0=20=D0=B2=20=D0=B8=D0=B3=D1=80=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../game/GameLifecycleTest.java | 97 +++++++++++++++++++ .../com/projectsacrifice/game/GameView.java | 19 ++++ .../projectsacrifice/game/MainActivity.java | 1 + 3 files changed, 117 insertions(+) create mode 100644 app/src/androidTest/java/com/projectsacrifice/game/GameLifecycleTest.java diff --git a/app/src/androidTest/java/com/projectsacrifice/game/GameLifecycleTest.java b/app/src/androidTest/java/com/projectsacrifice/game/GameLifecycleTest.java new file mode 100644 index 0000000..7a47f74 --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/GameLifecycleTest.java @@ -0,0 +1,97 @@ +package com.projectsacrifice.game; + +import android.app.Instrumentation; + +import androidx.lifecycle.Lifecycle; +import androidx.test.core.app.ActivityScenario; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** Device-level contract for leaving and returning to an active puzzle. */ +@RunWith(AndroidJUnit4.class) +public final class GameLifecycleTest { + @Test + public void returningFromBackgroundKeepsPuzzleVisiblePausedAndClearsHeldInput() { + Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); + try (ActivityScenario scenario = ActivityScenario.launch(MainActivity.class)) { + final GameView[] view = new GameView[1]; + scenario.onActivity(activity -> view[0] = activity.gameViewForAutomation()); + createRetainedCorpse(instrumentation, view[0]); + + runOnGame(instrumentation, view[0], false, false, true, false); + scenario.moveToState(Lifecycle.State.STARTED); + GameView.AutomationSnapshot backgroundStart = snapshot(instrumentation, view[0]); + sleep(700L); + GameView.AutomationSnapshot backgroundEnd = snapshot(instrumentation, view[0]); + + assertEquals(5, backgroundEnd.levelNumber()); + assertEquals(backgroundStart.deaths(), backgroundEnd.deaths()); + assertEquals(backgroundStart.corpseCount(), backgroundEnd.corpseCount()); + assertEquals(backgroundStart.playerX(), backgroundEnd.playerX(), 0.01f); + assertEquals(backgroundStart.elapsedMilliseconds(), backgroundEnd.elapsedMilliseconds()); + + scenario.moveToState(Lifecycle.State.RESUMED); + GameView.AutomationSnapshot returned = snapshot(instrumentation, view[0]); + assertTrue("Returning to an active puzzle must show the pause screen", returned.visiblyPaused()); + assertFalse("Held touch input must not survive backgrounding", returned.inputActive()); + assertEquals(backgroundEnd.deaths(), returned.deaths()); + assertEquals(backgroundEnd.corpseCount(), returned.corpseCount()); + + scenario.onActivity(MainActivity::onBackPressed); + sleep(80L); + GameView.AutomationSnapshot resumed = snapshot(instrumentation, view[0]); + assertFalse("Back from the visible pause screen must resume the puzzle", resumed.visiblyPaused()); + runOnGame(instrumentation, view[0], false, false, true, false); + sleep(250L); + runOnGame(instrumentation, view[0], false, false, false, false); + GameView.AutomationSnapshot moving = snapshot(instrumentation, view[0]); + assertTrue("The resumed simulation must accept movement", moving.playerX() > resumed.playerX() + 20f); + } + } + + private static void createRetainedCorpse(Instrumentation instrumentation, GameView gameView) { + runOnGame(instrumentation, gameView, true, false, true, false); + long deadline = System.currentTimeMillis() + 8_000L; + boolean died = false; + while (System.currentTimeMillis() < deadline) { + GameView.AutomationSnapshot value = snapshot(instrumentation, gameView); + if (!value.playerAlive()) died = true; + if (died && value.playerAlive() && value.corpseCount() >= 1) { + runOnGame(instrumentation, gameView, false, false, false, false); + return; + } + sleep(24L); + } + throw new AssertionError("Level 5 did not produce a retained corpse before lifecycle check"); + } + + private static void runOnGame(Instrumentation instrumentation, GameView gameView, + boolean startLevelFive, boolean left, boolean right, boolean jump) { + instrumentation.runOnMainSync(() -> { + if (startLevelFive) gameView.startLevelForAutomation(5); + gameView.setAutomationInput(left, right, jump); + }); + } + + private static GameView.AutomationSnapshot snapshot(Instrumentation instrumentation, GameView gameView) { + final GameView.AutomationSnapshot[] value = new GameView.AutomationSnapshot[1]; + instrumentation.runOnMainSync(() -> value[0] = gameView.automationSnapshot()); + return value[0]; + } + + private static void sleep(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError("Lifecycle test interrupted", interrupted); + } + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/GameView.java b/app/src/main/java/com/projectsacrifice/game/GameView.java index 12874df..a258a74 100644 --- a/app/src/main/java/com/projectsacrifice/game/GameView.java +++ b/app/src/main/java/com/projectsacrifice/game/GameView.java @@ -45,16 +45,26 @@ public final class GameView extends View implements Runnable { private final boolean completed; private final boolean playerAlive; private final boolean allDoorsOpen; + private final boolean visiblyPaused; + private final boolean inputActive; + private final int levelNumber; + private final long elapsedMilliseconds; private final float playerX; private final float playerY; private final int deaths; private final int corpseCount; AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen, + boolean visiblyPaused, boolean inputActive, int levelNumber, + long elapsedMilliseconds, float playerX, float playerY, int deaths, int corpseCount) { this.completed = completed; this.playerAlive = playerAlive; this.allDoorsOpen = allDoorsOpen; + this.visiblyPaused = visiblyPaused; + this.inputActive = inputActive; + this.levelNumber = levelNumber; + this.elapsedMilliseconds = elapsedMilliseconds; this.playerX = playerX; this.playerY = playerY; this.deaths = deaths; @@ -64,6 +74,10 @@ public final class GameView extends View implements Runnable { public boolean completed() { return completed; } public boolean playerAlive() { return playerAlive; } public boolean allDoorsOpen() { return allDoorsOpen; } + public boolean visiblyPaused() { return visiblyPaused; } + public boolean inputActive() { return inputActive; } + public int levelNumber() { return levelNumber; } + public long elapsedMilliseconds() { return elapsedMilliseconds; } public float playerX() { return playerX; } public float playerY() { return playerY; } public int deaths() { return deaths; } @@ -174,6 +188,7 @@ public final class GameView extends View implements Runnable { public void setPaused(boolean value) { paused = value; + if (value && screen == Screen.PLAYING) userPaused = true; pointers.clear(); deriveControls(); lastFrameNanos = System.nanoTime(); @@ -1117,6 +1132,10 @@ public final class GameView extends View implements Runnable { screen == Screen.COMPLETE, player.alive, allOpen, + userPaused, + leftPressed || rightPressed || jumpPressed, + level == null ? 0 : level.number, + (long) (levelElapsed * 1000f), player.x, player.y, deaths, diff --git a/app/src/main/java/com/projectsacrifice/game/MainActivity.java b/app/src/main/java/com/projectsacrifice/game/MainActivity.java index 690a60f..26264bd 100644 --- a/app/src/main/java/com/projectsacrifice/game/MainActivity.java +++ b/app/src/main/java/com/projectsacrifice/game/MainActivity.java @@ -36,6 +36,7 @@ public final class MainActivity extends Activity { @Override protected void onResume() { super.onResume(); + if (gameView != null) gameView.setPaused(false); hideSystemUi(); }