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
70 changes: 61 additions & 9 deletions Assets/Tests/Editor/ExternalSceneFocusReturnDeferralTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using NUnit.Framework;
using UnityEditor;

using io.github.hatayama.UnityCliLoop.FirstPartyTools;

Expand Down Expand Up @@ -36,26 +37,77 @@ public void ShouldResolveOnFocusReturn_WhenPlaying_ReturnsFalseAndDefers()
}

[Test]
public void ConsumeOnEnteredEditMode_WhenDeferred_ReturnsTrueOnceAndClears()
public void ShouldResolveOnPlayModeStateChange_WhenDeferralRestoredAfterReload_ResolvesOnceAndClears()
{
// Verifies a deferred focus return runs exactly once after Play Mode ends.
// Verifies a deferral restored from SessionState after a Play Mode domain reload runs exactly once
// when Edit Mode returns, even though this instance never saw the Editor leave Edit Mode.
ExternalSceneFocusReturnDeferral deferral = new ExternalSceneFocusReturnDeferral(isDeferred: true);

bool firstConsume = deferral.ConsumeOnEnteredEditMode();
bool secondConsume = deferral.ConsumeOnEnteredEditMode();
bool firstResolve = deferral.ShouldResolveOnPlayModeStateChange(
PlayModeStateChange.EnteredEditMode,
isFocused: true);
bool secondResolve = deferral.ShouldResolveOnPlayModeStateChange(
PlayModeStateChange.EnteredEditMode,
isFocused: true);

Assert.That(firstConsume, Is.True);
Assert.That(secondConsume, Is.False);
Assert.That(firstResolve, Is.True);
Assert.That(secondResolve, Is.False);
Assert.That(deferral.IsDeferred, Is.False);
}

[Test]
public void ConsumeOnEnteredEditMode_WhenNothingDeferred_ReturnsFalse()
public void ShouldResolveOnPlayModeStateChange_AfterAPlaySession_ResolvesOnceEvenWhenUnfocused()
{
// Verifies leaving Play Mode without a skipped focus return does not trigger a resolve.
// Verifies every Play session runs one preflight when Edit Mode returns, without a focus return and
// while unfocused, because Unity's own post-Play import otherwise raises the reload dialog after Stop.
ExternalSceneFocusReturnDeferral deferral = new ExternalSceneFocusReturnDeferral(isDeferred: false);

Assert.That(deferral.ConsumeOnEnteredEditMode(), Is.False);
bool resolvesOnExitingEditMode = deferral.ShouldResolveOnPlayModeStateChange(
PlayModeStateChange.ExitingEditMode,
isFocused: false);
bool resolvesOnEnteredPlayMode = deferral.ShouldResolveOnPlayModeStateChange(
PlayModeStateChange.EnteredPlayMode,
isFocused: false);
bool resolvesOnEnteredEditMode = deferral.ShouldResolveOnPlayModeStateChange(
PlayModeStateChange.EnteredEditMode,
isFocused: false);
bool resolvesOnSecondEnteredEditMode = deferral.ShouldResolveOnPlayModeStateChange(
PlayModeStateChange.EnteredEditMode,
isFocused: false);

Assert.That(resolvesOnExitingEditMode, Is.False);
Assert.That(resolvesOnEnteredPlayMode, Is.False);
Assert.That(resolvesOnEnteredEditMode, Is.True);
Assert.That(resolvesOnSecondEnteredEditMode, Is.False);
}

[Test]
public void ShouldResolveOnPlayModeStateChange_WhenLeavingEditMode_KeepsFingerprintsAcrossPlayReloads()
{
// Verifies a domain reload while entering or leaving Play Mode keeps the pre-Play fingerprints,
// so an external change made before or during Play is still detected when Edit Mode returns.
ExternalSceneFocusReturnDeferral deferral = new ExternalSceneFocusReturnDeferral(isDeferred: false);

deferral.ShouldResolveOnPlayModeStateChange(PlayModeStateChange.ExitingEditMode, isFocused: true);
bool shouldRecord = deferral.ShouldRecordBaselineOnInitialize(
isFocused: true,
restoredSceneSnapshots: true);

Assert.That(shouldRecord, Is.False);
Assert.That(deferral.IsDeferred, Is.True);
}

[Test]
public void ShouldResolveOnPlayModeStateChange_WhenEditModeReturnsWithoutAPlaySession_DoesNotResolve()
{
// Verifies Edit Mode return resolves only for a scheduled preflight, not on every state change.
ExternalSceneFocusReturnDeferral deferral = new ExternalSceneFocusReturnDeferral(isDeferred: false);

bool shouldResolve = deferral.ShouldResolveOnPlayModeStateChange(
PlayModeStateChange.EnteredEditMode,
isFocused: true);

Assert.That(shouldResolve, Is.False);
}

[Test]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ private static void HandleFocusChanged(bool isFocused)

private static void HandlePlayModeStateChanged(PlayModeStateChange state)
{
bool shouldResolve = _focusReturnDeferral.ShouldResolveOnPlayModeStateChange(
state,
EditorApplication.isFocused);
// The deferral must outlive the domain reloads on both sides of Play Mode.
SessionState.SetBool(FocusReturnDeferredSessionStateKey, _focusReturnDeferral.IsDeferred);
if (state != PlayModeStateChange.EnteredEditMode)
{
return;
Expand All @@ -115,15 +120,12 @@ private static void HandlePlayModeStateChanged(PlayModeStateChange state)
SaveSceneSnapshotsToSessionState();
}

bool shouldResolve = _focusReturnDeferral.ConsumeOnEnteredEditMode();
SessionState.SetBool(FocusReturnDeferredSessionStateKey, false);
VibeLogger.LogInfo(
"external_scene_entered_edit_mode",
"Entered Edit Mode",
new { prunedScenePaths, shouldResolve },
includeStackTrace: false);
// An unfocused Editor gets the same preflight from its next focus return.
if (shouldResolve && EditorApplication.isFocused)
if (shouldResolve)
{
ResolveForFocusReturn();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
using UnityEditor;

namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
{
/// <summary>
/// Remembers that a focus-return Scene preflight was skipped during Play Mode so it can run once Edit Mode returns.
/// Remembers that the Scene preflight must run once Edit Mode returns: for a focus return skipped during
/// Play Mode, and for every Play session.
/// </summary>
internal sealed class ExternalSceneFocusReturnDeferral
{
Expand All @@ -28,6 +31,30 @@ public bool ShouldResolveOnFocusReturn(bool isPlayingOrWillChangePlaymode)
return false;
}

/// <summary>
/// Decides whether the preflight must run for this Play Mode transition. Leaving Edit Mode schedules
/// one preflight for when Edit Mode returns, and that preflight runs whether or not the Editor is
/// focused: Unity's own post-Play refresh can import a changed Scene before any focus return, and only
/// the preflight's import-then-reload keeps the reload dialog from appearing. The caller passes the focus
/// state and resolves exactly when this returns true, so ignoring focus stays part of this tested decision
/// instead of a condition the caller could add back.
/// </summary>
public bool ShouldResolveOnPlayModeStateChange(PlayModeStateChange state, bool isFocused)
{
if (state == PlayModeStateChange.ExitingEditMode)
{
IsDeferred = true;
return false;
}

if (state != PlayModeStateChange.EnteredEditMode)
{
return false;
}

return ConsumeDeferral();
}

/// <summary>
/// Decides whether Initialize may replace the restored fingerprints with the current disk state.
/// A deferred focus return needs the pre-reload fingerprints to compare against, so re-recording
Expand All @@ -38,10 +65,7 @@ public bool ShouldRecordBaselineOnInitialize(bool isFocused, bool restoredSceneS
return !restoredSceneSnapshots || (isFocused && !IsDeferred);
}

/// <summary>
/// Returns whether a deferred preflight must run now, and clears the deferral so it runs only once.
/// </summary>
public bool ConsumeOnEnteredEditMode()
private bool ConsumeDeferral()
{
bool wasDeferred = IsDeferred;
IsDeferred = false;
Expand Down
14 changes: 11 additions & 3 deletions docs/focus-return-asset-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,17 @@ Scene kept the exception firing on every later focus return.

On `PlayModeStateChange.EnteredEditMode` the tracker first drops fingerprints for Scenes that
are no longer open in the Editor (runtime-loaded Scenes never get a lasting baseline), then
runs the preflight once if a focus return was deferred and the Editor is focused. An unfocused
Editor leaves it to the next focus return instead. The deferral flag lives in `SessionState`
because leaving Play Mode can trigger a domain reload. The decision is isolated in
runs the preflight once. It does so after every Play session, not only when a focus return
was deferred, and whether or not the Editor is focused: `ExitingEditMode` schedules it. A
Scene file changed during Play Mode, or while the Editor stayed focused, is followed by no
focus return. With Auto Refresh enabled, Unity imports it right after Play Mode ends and
raises the "modified externally" dialog, which also blocks `control-play-mode --action Stop`.
The preflight's import-then-reload keeps the dialog away. This was confirmed on Unity
2022.3.62f3 with domain reload on Play both disabled and enabled (#3047).

The deferral flag lives in `SessionState` because entering and leaving Play Mode can trigger a
domain reload. While it is set, `Initialize` keeps the restored fingerprints instead of
recording the current disk state, so a change made before or during Play is still detected. The decision is isolated in
`ExternalSceneFocusReturnDeferral` and `ExternalSceneSnapshotPruner`
(`Assets/Tests/Editor/ExternalSceneFocusReturnDeferralTests.cs`).

Expand Down
Loading