Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,60 @@ public async Task ExecuteAsync_WhenPlayBlockedWithoutSavedErrors_ReturnsEmptyDia
Assert.That(response.Message, Does.Contain("no saved compiler diagnostics"));
}

[Test]
public async Task ExecuteAsync_WhenPlayStartSaveFails_DoesNotEnterPlayMode()
{
// Verifies dirty Scene/Prefab save failure blocks Edit→Play instead of prompting or hanging.
Assert.That(EditorApplication.isPlaying, Is.False);
StubEditorUnsavedChangesQuietSaver quietSaver = new(
saveFailures: new[] { "Scene: Assets/Scenes/Sample.unity" },
remainingAfterSave: System.Array.Empty<string>());
ControlPlayModeUseCase useCase = new ControlPlayModeUseCase(
new StubCompilationFailureProvider(System.Array.Empty<ControlPlayModeCompileError>()),
new StubCompilationFailureGate(false),
quietSaver);
ControlPlayModeSchema schema = new ControlPlayModeSchema
{
Action = PlayModeAction.Play,
};

ControlPlayModeResponse response = await useCase.ExecuteAsync(schema, CancellationToken.None);

Assert.That(quietSaver.SaveCallCount, Is.EqualTo(1));
Assert.That(EditorApplication.isPlaying, Is.False);
Assert.That(response.Changed, Is.False);
Assert.That(response.IsPlaying, Is.False);
Assert.That(response.Message, Does.Contain("could not be saved"));
Assert.That(response.Message, Does.Contain("Scene: Assets/Scenes/Sample.unity"));
}

[Test]
public async Task ExecuteAsync_WhenPlayStartLeavesUnsavedChanges_DoesNotEnterPlayMode()
{
// Verifies remaining dirty editor state after a quiet save still blocks Play start.
Assert.That(EditorApplication.isPlaying, Is.False);
StubEditorUnsavedChangesQuietSaver quietSaver = new(
saveFailures: System.Array.Empty<string>(),
remainingAfterSave: new[] { "Prefab Stage: Assets/Prefabs/Hud.prefab" });
ControlPlayModeUseCase useCase = new ControlPlayModeUseCase(
new StubCompilationFailureProvider(System.Array.Empty<ControlPlayModeCompileError>()),
new StubCompilationFailureGate(false),
quietSaver);
ControlPlayModeSchema schema = new ControlPlayModeSchema
{
Action = PlayModeAction.Play,
};

ControlPlayModeResponse response = await useCase.ExecuteAsync(schema, CancellationToken.None);

Assert.That(quietSaver.SaveCallCount, Is.EqualTo(1));
Assert.That(quietSaver.DetectCallCount, Is.EqualTo(1));
Assert.That(EditorApplication.isPlaying, Is.False);
Assert.That(response.Changed, Is.False);
Assert.That(response.Message, Does.Contain("unsaved scene or prefab changes"));
Assert.That(response.Message, Does.Contain("Prefab Stage: Assets/Prefabs/Hud.prefab"));
}

private sealed class StubCompilationFailureProvider : IControlPlayModeCompilationFailureProvider
{
private readonly ControlPlayModeCompileError[] _errors;
Expand Down Expand Up @@ -289,5 +343,32 @@ public bool HasScriptCompilationFailed()
return _hasScriptCompilationFailed;
}
}

private sealed class StubEditorUnsavedChangesQuietSaver : IEditorUnsavedChangesQuietSaver
{
private readonly string[] _saveFailures;
private readonly string[] _remainingAfterSave;

public int SaveCallCount { get; private set; }
public int DetectCallCount { get; private set; }

public StubEditorUnsavedChangesQuietSaver(string[] saveFailures, string[] remainingAfterSave)
{
_saveFailures = saveFailures;
_remainingAfterSave = remainingAfterSave;
}

public string[] DetectUnsavedEditorChanges()
{
DetectCallCount++;
return _remainingAfterSave;
}

public string[] SaveUnsavedEditorChanges()
{
SaveCallCount++;
return _saveFailures;
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
using System.Collections.Generic;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;

namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
{
/// <summary>
/// Quietly saves dirty loaded Scenes and the current Prefab Stage without prompting the user.
/// Shared by run-tests and control-play-mode so Play Mode start and test runs use the same path.
/// </summary>
public sealed class EditorUnsavedChangesQuietSaver : IEditorUnsavedChangesQuietSaver
{
public string[] DetectUnsavedEditorChanges()
{
List<string> unsavedEditorChanges = new();
AddDirtyLoadedScenes(unsavedEditorChanges);
AddDirtyPrefabStage(unsavedEditorChanges);
return unsavedEditorChanges.ToArray();
}

public string[] SaveUnsavedEditorChanges()
{
List<string> failedChanges = new();
SaveDirtyLoadedScenes(failedChanges);
SaveDirtyPrefabStage(failedChanges);
return failedChanges.ToArray();
}

private static void AddDirtyLoadedScenes(List<string> unsavedEditorChanges)
{
Debug.Assert(unsavedEditorChanges != null, "unsavedEditorChanges must not be null");

for (int i = 0; i < SceneManager.sceneCount; i++)
{
Scene scene = SceneManager.GetSceneAt(i);
if (!scene.IsValid() || !scene.isLoaded || !scene.isDirty)
{
continue;
}

unsavedEditorChanges.Add("Scene: " + GetSceneDisplayPath(scene));
}
}

private static void SaveDirtyLoadedScenes(List<string> failedChanges)
{
Debug.Assert(failedChanges != null, "failedChanges must not be null");

for (int i = 0; i < SceneManager.sceneCount; i++)
{
Scene scene = SceneManager.GetSceneAt(i);
if (!scene.IsValid() || !scene.isLoaded || !scene.isDirty)
{
continue;
}

if (string.IsNullOrEmpty(scene.path) || !EditorSceneManager.SaveScene(scene))
{
failedChanges.Add("Scene: " + GetSceneDisplayPath(scene));
}
}
}

private static void AddDirtyPrefabStage(List<string> unsavedEditorChanges)
{
Debug.Assert(unsavedEditorChanges != null, "unsavedEditorChanges must not be null");

PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
if (prefabStage == null || !prefabStage.scene.IsValid() || !prefabStage.scene.isDirty)
{
return;
}

unsavedEditorChanges.Add("Prefab Stage: " + GetPrefabStageDisplayPath(prefabStage));
}

private static void SaveDirtyPrefabStage(List<string> failedChanges)
{
Debug.Assert(failedChanges != null, "failedChanges must not be null");

PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
if (prefabStage == null || !prefabStage.scene.IsValid() || !prefabStage.scene.isDirty)
{
return;
}

if (!SavePrefabStage(prefabStage))
{
failedChanges.Add("Prefab Stage: " + GetPrefabStageDisplayPath(prefabStage));
}
}

private static bool SavePrefabStage(PrefabStage prefabStage)
{
Debug.Assert(prefabStage != null, "prefabStage must not be null");

if (string.IsNullOrEmpty(prefabStage.assetPath))
{
return false;
}

bool success;
PrefabUtility.SaveAsPrefabAsset(prefabStage.prefabContentsRoot, prefabStage.assetPath, out success);
if (success)
{
prefabStage.ClearDirtiness();
}

return success;
}

private static string GetSceneDisplayPath(Scene scene)
{
if (!string.IsNullOrEmpty(scene.path))
{
return scene.path;
}

if (!string.IsNullOrEmpty(scene.name))
{
return scene.name;
}

return "Untitled scene";
}

private static string GetPrefabStageDisplayPath(PrefabStage prefabStage)
{
Debug.Assert(prefabStage != null, "prefabStage must not be null");

if (!string.IsNullOrEmpty(prefabStage.assetPath))
{
return prefabStage.assetPath;
}

return GetSceneDisplayPath(prefabStage.scene);
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
{
/// <summary>
/// Detects and quietly saves dirty Editor Scene / Prefab Stage state without user prompts.
/// </summary>
public interface IEditorUnsavedChangesQuietSaver
{
string[] DetectUnsavedEditorChanges();

/// <summary>
/// Saves dirty loaded Scenes and the current Prefab Stage.
/// Returns display paths that could not be saved; empty when every dirty item saved.
/// </summary>
string[] SaveUnsavedEditorChanges();
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Threading;
using System.Threading.Tasks;
using UnityEditor;
using UnityEngine;

namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
{
Expand All @@ -12,17 +13,26 @@ public class ControlPlayModeUseCase
{
public const int DefaultTimeoutSeconds = 180;

private const string UnsavedEditorChangesSaveFailureMessage =
"Play mode could not start because unsaved scene or prefab changes could not be saved.";
private const string UnsavedEditorChangesRemainingFailureMessage =
"Play mode could not start while the editor has unsaved scene or prefab changes.";

private readonly IControlPlayModeCompilationFailureProvider _compilationFailureProvider;
private readonly IControlPlayModeCompilationFailureGate _compilationFailureGate;
private readonly IEditorUnsavedChangesQuietSaver _unsavedChangesQuietSaver;

public ControlPlayModeUseCase(
IControlPlayModeCompilationFailureProvider compilationFailureProvider = null,
IControlPlayModeCompilationFailureGate compilationFailureGate = null)
IControlPlayModeCompilationFailureGate compilationFailureGate = null,
IEditorUnsavedChangesQuietSaver unsavedChangesQuietSaver = null)
{
_compilationFailureProvider =
compilationFailureProvider ?? ControlPlayModeServices.CompilationFailureProvider;
_compilationFailureGate =
compilationFailureGate ?? ControlPlayModeServices.CompilationFailureGate;
_unsavedChangesQuietSaver =
unsavedChangesQuietSaver ?? new EditorUnsavedChangesQuietSaver();
}

public Task<ControlPlayModeResponse> ExecuteAsync(ControlPlayModeSchema parameters, CancellationToken ct)
Expand Down Expand Up @@ -108,6 +118,17 @@ private ControlPlayModeActionResult ExecutePlayModeStart(bool wasPaused, bool wa
true);
}

// 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)
{
ControlPlayModeActionResult saveResult = SaveDirtyEditorChangesBeforePlayStart();
if (saveResult.HasResponse)
{
return saveResult;
}
}

if (wasPaused)
{
EditorApplication.isPaused = false;
Expand All @@ -124,6 +145,29 @@ private ControlPlayModeActionResult ExecutePlayModeStart(bool wasPaused, bool wa
return ControlPlayModeActionResult.FromState(message, changed, false);
}

private ControlPlayModeActionResult SaveDirtyEditorChangesBeforePlayStart()
{
string[] failedChanges = _unsavedChangesQuietSaver.SaveUnsavedEditorChanges();
Debug.Assert(failedChanges != null, "Unsaved editor change save must return an array");
if (failedChanges.Length > 0)
{
return ControlPlayModeActionResult.FromResponse(
CreateSaveFailedResponse(UnsavedEditorChangesSaveFailureMessage, failedChanges),
false);
}

string[] remainingChanges = _unsavedChangesQuietSaver.DetectUnsavedEditorChanges();
Debug.Assert(remainingChanges != null, "Unsaved editor change detection must return an array");
if (remainingChanges.Length > 0)
{
return ControlPlayModeActionResult.FromResponse(
CreateSaveFailedResponse(UnsavedEditorChangesRemainingFailureMessage, remainingChanges),
false);
}

return ControlPlayModeActionResult.FromState(string.Empty, false, false);
}

private static ControlPlayModeActionResult ExecutePlayModeStop(bool wasPaused, bool wasPlaying)
{
bool wasAlreadyStopped = !wasPlaying;
Expand Down Expand Up @@ -172,6 +216,16 @@ private static ControlPlayModeResponse CreateResponse(string message, bool chang
return response;
}

private static ControlPlayModeResponse CreateSaveFailedResponse(string messagePrefix, string[] failedChanges)
{
Debug.Assert(!string.IsNullOrEmpty(messagePrefix), "messagePrefix must not be null or empty");
Debug.Assert(failedChanges != null, "failedChanges must not be null");
Debug.Assert(failedChanges.Length > 0, "failedChanges must not be empty");

string message = messagePrefix + " Unsaved changes: " + string.Join(", ", failedChanges);
return CreateResponse(message, false, false);
}

private static ControlPlayModeResponse CreateCompileErrorBlockedResponse(
ControlPlayModeCompileError[] compileErrors)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"name": "UnityCLILoop.FirstPartyTools.ControlPlayMode.Editor",
"rootNamespace": "io.github.hatayama.UnityCliLoop.FirstPartyTools",
"references": [
"GUID:d427b32aad9cb44fc8e962437c9dbcd8",
"GUID:fc3fd32eddbee40e39c2d76dc184957b"
],
"includePlatforms": [
Expand Down
Loading