diff --git a/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs b/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs index 236bbd264..c359930f5 100644 --- a/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs +++ b/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs @@ -493,6 +493,125 @@ public async Task ExecuteAsync_WhenPlayResumesFromPause_OnlyClearsPauseAndReport Assert.That(quietSaver.SaveCallCount, Is.EqualTo(0)); } + /// + /// What: Play while already running and not paused is a no-op with the already-running message. + /// + [Test] + public async Task ExecuteAsync_WhenPlayWhileAlreadyRunning_ReportsNoOpWithoutSideEffects() + { + FakeControlPlayModeEditorStateService editorState = new(isPlaying: true, isPaused: false); + StubEditorUnsavedChangesQuietSaver quietSaver = new( + saveFailures: System.Array.Empty(), + remainingAfterSave: System.Array.Empty()); + ControlPlayModeUseCase useCase = new ControlPlayModeUseCase( + new StubCompilationFailureProvider(System.Array.Empty()), + new StubCompilationFailureGate(false), + quietSaver, + editorState, + new StubDomainReloadDropStateProvider()); + ControlPlayModeSchema schema = new ControlPlayModeSchema + { + Action = PlayModeAction.Play, + }; + + ControlPlayModeResponse response = await useCase.ExecuteAsync(schema, CancellationToken.None); + + Assert.That( + response.Message, + Is.EqualTo("Play mode was already running; nothing to start or resume.")); + Assert.That(response.Changed, Is.False); + Assert.That(response.ResumedFromPause, Is.False); + Assert.That(response.Warning, Is.Empty); + Assert.That(response.IsPlaying, Is.True); + Assert.That(response.IsPaused, Is.False); + Assert.That(editorState.IsPlayingSetCount, Is.EqualTo(0)); + Assert.That(editorState.IsPausedSetCount, Is.EqualTo(0)); + Assert.That(quietSaver.SaveCallCount, Is.EqualTo(0)); + } + + /// + /// What: Resume while already running and not paused uses the same no-op contract as Play. + /// + [Test] + public async Task ExecuteAsync_WhenResumeWhileAlreadyRunning_ReportsNoOpWithoutSideEffects() + { + FakeControlPlayModeEditorStateService editorState = new(isPlaying: true, isPaused: false); + StubEditorUnsavedChangesQuietSaver quietSaver = new( + saveFailures: System.Array.Empty(), + remainingAfterSave: System.Array.Empty()); + ControlPlayModeUseCase useCase = new ControlPlayModeUseCase( + new StubCompilationFailureProvider(System.Array.Empty()), + new StubCompilationFailureGate(false), + quietSaver, + editorState, + new StubDomainReloadDropStateProvider()); + ControlPlayModeSchema schema = new ControlPlayModeSchema + { + Action = PlayModeAction.Resume, + }; + + ControlPlayModeResponse response = await useCase.ExecuteAsync(schema, CancellationToken.None); + + Assert.That( + response.Message, + Is.EqualTo("Play mode was already running; nothing to start or resume.")); + Assert.That(response.Changed, Is.False); + Assert.That(response.ResumedFromPause, Is.False); + Assert.That(response.Warning, Is.Empty); + Assert.That(response.IsPlaying, Is.True); + Assert.That(response.IsPaused, Is.False); + Assert.That(editorState.IsPlayingSetCount, Is.EqualTo(0)); + Assert.That(editorState.IsPausedSetCount, Is.EqualTo(0)); + Assert.That(quietSaver.SaveCallCount, Is.EqualTo(0)); + } + + /// + /// What: a failed compile gate does not replace the already-running no-op while Play is live. + /// + [Test] + public async Task ExecuteAsync_WhenPlayWhileAlreadyRunningAndCompileFailed_ReportsNoOpNotCompileBlock() + { + FakeControlPlayModeEditorStateService editorState = new(isPlaying: true, isPaused: false); + StubEditorUnsavedChangesQuietSaver quietSaver = new( + saveFailures: System.Array.Empty(), + remainingAfterSave: System.Array.Empty()); + ControlPlayModeCompileError[] compileErrors = + { + new ControlPlayModeCompileError + { + Message = "CS1525: invalid expression", + File = "Assets/Scripts/Sample.cs", + Line = 3 + } + }; + ControlPlayModeUseCase useCase = new ControlPlayModeUseCase( + new StubCompilationFailureProvider(compileErrors), + new StubCompilationFailureGate(true), + quietSaver, + editorState, + new StubDomainReloadDropStateProvider()); + ControlPlayModeSchema schema = new ControlPlayModeSchema + { + Action = PlayModeAction.Play, + }; + + ControlPlayModeResponse response = await useCase.ExecuteAsync(schema, CancellationToken.None); + + Assert.That( + response.Message, + Is.EqualTo("Play mode was already running; nothing to start or resume.")); + Assert.That(response.Changed, Is.False); + Assert.That(response.ResumedFromPause, Is.False); + Assert.That(response.Warning, Is.Empty); + Assert.That(response.IsPlaying, Is.True); + Assert.That(response.IsPaused, Is.False); + Assert.That(response.BlockedByCompileErrors, Is.False); + Assert.That(response.CompileErrorCount, Is.EqualTo(0)); + Assert.That(editorState.IsPlayingSetCount, Is.EqualTo(0)); + Assert.That(editorState.IsPausedSetCount, Is.EqualTo(0)); + Assert.That(quietSaver.SaveCallCount, Is.EqualTo(0)); + } + [Test] public async Task ExecuteAsync_WhenPlayStartsFreshSession_SetsPlayingAndReportsWarning() { diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeConstants.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeConstants.cs index 29154e236..4d5a6dbb8 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeConstants.cs @@ -10,5 +10,8 @@ internal static class ControlPlayModeConstants internal const string StoppedByCliRunTestsCancel = "cli-run-tests-cancel"; internal const string StoppedByScriptCompilation = "script-compilation"; internal const string StoppedByUnknown = "unknown"; + + internal const string AlreadyRunningPlayMessage = + "Play mode was already running; nothing to start or resume."; } } diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs index 8d7a5483c..cfdd3fa3c 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs @@ -173,6 +173,16 @@ private ControlPlayModeActionResult ExecutePlayModeStart(bool wasPaused, bool wa true); } + // Why: already-running Play used to report "Play mode started" even when Changed + // was false, which made a no-op look like a new session. + if (wasPlaying && !wasPaused) + { + return ControlPlayModeActionResult.FromState( + ControlPlayModeConstants.AlreadyRunningPlayMessage, + false, + false); + } + // Why only when entering Play from Edit: SaveScene does not work while already playing, // and resume-from-pause must not rewrite Scene assets. if (!wasPlaying)