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
1 change: 0 additions & 1 deletion .agents/skills/uloop-focus-window/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,4 @@ Returns JSON with:

## Notes

- **Works even when Unity is busy** (compiling, domain reload, etc.)
- Useful before `uloop screenshot` to ensure the target window is visible
9 changes: 8 additions & 1 deletion .agents/skills/uloop-launch/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: uloop-launch
description: "Launch or restart Unity Editor. Use when Unity is not running or unresponsive."
description: "Launch or restart Unity Editor. Use only when Unity is not running or stays frozen after retries — not as a health check after a failed command; it brings the Unity window to the foreground as a side effect."
---

# uloop launch
Expand Down Expand Up @@ -46,6 +46,13 @@ The final JSON payload includes:
- `ProjectRoot`: resolved project root
- `Message`: readiness summary

## When not to use

A single failed, cancelled, or busy command (e.g. right after a domain reload) is not a reason
to launch — retry the command instead. Running launch while the Editor is up brings the Unity
window to the foreground, which disrupts the user. Reach for launch only when Unity is not
running, or still does not respond after retries.

## Notes

- If Unity is already running, focuses the existing window and verifies tool readiness
Expand Down
1 change: 0 additions & 1 deletion .claude/skills/uloop-focus-window/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,4 @@ Returns JSON with:

## Notes

- **Works even when Unity is busy** (compiling, domain reload, etc.)
- Useful before `uloop screenshot` to ensure the target window is visible
9 changes: 8 additions & 1 deletion .claude/skills/uloop-launch/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: uloop-launch
description: "Launch or restart Unity Editor. Use when Unity is not running or unresponsive."
description: "Launch or restart Unity Editor. Use only when Unity is not running or stays frozen after retries — not as a health check after a failed command; it brings the Unity window to the foreground as a side effect."
---

# uloop launch
Expand Down Expand Up @@ -46,6 +46,13 @@ The final JSON payload includes:
- `ProjectRoot`: resolved project root
- `Message`: readiness summary

## When not to use

A single failed, cancelled, or busy command (e.g. right after a domain reload) is not a reason
to launch — retry the command instead. Running launch while the Editor is up brings the Unity
window to the foreground, which disrupts the user. Reach for launch only when Unity is not
running, or still does not respond after retries.

## Notes

- If Unity is already running, focuses the existing window and verifies tool readiness
Expand Down
95 changes: 0 additions & 95 deletions Assets/Tests/Editor/AutoTickPumpControllerTests.cs

This file was deleted.

232 changes: 232 additions & 0 deletions Assets/Tests/Editor/PlayModeFocusSuppressionServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
using System.Collections.Generic;
using NUnit.Framework;

using io.github.hatayama.UnityCliLoop.FirstPartyTools;

namespace io.github.hatayama.UnityCliLoop.Tests.Editor
{
/// <summary>
/// Tests the pure focus-gated Play Mode window-raise suppression state machine.
/// </summary>
public sealed class PlayModeFocusSuppressionServiceTests
{
private sealed class FakeEnvironment
{
internal bool IsFocused;
internal bool SuppressedFlag;
internal int SuppressCallCount;
internal int RestoreCallCount;
internal int SuppressChangedViews;
internal int RestoreChangedViews;
internal readonly List<string> LoggedOperations = new List<string>();

internal PlayModeFocusSuppressionService CreateService()
{
return new PlayModeFocusSuppressionService(
() => IsFocused,
() =>
{
SuppressCallCount++;
return SuppressChangedViews;
},
() =>
{
RestoreCallCount++;
return RestoreChangedViews;
},
() => SuppressedFlag,
value => SuppressedFlag = value,
(operation, message, context) => LoggedOperations.Add(operation));
}
}

/// <summary>
/// Verifies focus loss calls the suppress action and sets the persisted flag when views changed.
/// </summary>
[Test]
public void HandleFocusChanged_FocusLostWithChangedViews_SetsFlagAndSuppresses()
{
FakeEnvironment environment = new FakeEnvironment { SuppressChangedViews = 1 };
PlayModeFocusSuppressionService service = environment.CreateService();

service.HandleFocusChanged(false);

Assert.That(environment.SuppressCallCount, Is.EqualTo(1));
Assert.That(environment.SuppressedFlag, Is.True);
}

/// <summary>
/// Verifies focus loss leaves the flag clear when no view needed suppression.
/// </summary>
[Test]
public void HandleFocusChanged_FocusLostWithNoChangedViews_LeavesFlagClear()
{
FakeEnvironment environment = new FakeEnvironment { SuppressChangedViews = 0 };
PlayModeFocusSuppressionService service = environment.CreateService();

service.HandleFocusChanged(false);

Assert.That(environment.SuppressedFlag, Is.False);
}

/// <summary>
/// Verifies focus loss keeps an already-set flag even when this call changed no views.
/// </summary>
[Test]
public void HandleFocusChanged_FocusLostWithFlagAlreadySet_KeepsFlagSet()
{
FakeEnvironment environment = new FakeEnvironment { SuppressedFlag = true, SuppressChangedViews = 0 };
PlayModeFocusSuppressionService service = environment.CreateService();

service.HandleFocusChanged(false);

Assert.That(environment.SuppressedFlag, Is.True);
}

/// <summary>
/// Verifies focus gain restores views and clears the flag when the flag is set.
/// </summary>
[Test]
public void HandleFocusChanged_FocusGainedWithFlagSet_RestoresAndClearsFlag()
{
FakeEnvironment environment = new FakeEnvironment
{
IsFocused = true,
SuppressedFlag = true,
RestoreChangedViews = 1
};
PlayModeFocusSuppressionService service = environment.CreateService();

service.HandleFocusChanged(true);

Assert.That(environment.RestoreCallCount, Is.EqualTo(1));
Assert.That(environment.SuppressedFlag, Is.False);
}

/// <summary>
/// Verifies focus gain never calls the restore action while the flag is clear.
/// </summary>
[Test]
public void HandleFocusChanged_FocusGainedWithFlagClear_DoesNotCallRestore()
{
FakeEnvironment environment = new FakeEnvironment { IsFocused = true };
PlayModeFocusSuppressionService service = environment.CreateService();

service.HandleFocusChanged(true);

Assert.That(environment.RestoreCallCount, Is.EqualTo(0));
}

/// <summary>
/// Verifies focus gain clears the flag even when restore changed no views (views were closed).
/// </summary>
[Test]
public void HandleFocusChanged_FocusGainedWithFlagSetAndNoViews_StillClearsFlag()
{
FakeEnvironment environment = new FakeEnvironment
{
IsFocused = true,
SuppressedFlag = true,
RestoreChangedViews = 0
};
PlayModeFocusSuppressionService service = environment.CreateService();

service.HandleFocusChanged(true);

Assert.That(environment.SuppressedFlag, Is.False);
Assert.That(environment.LoggedOperations, Does.Contain("play_focus_suppress_released"));
}

/// <summary>
/// Verifies reconcile arms suppression while the Editor is unfocused (no focus-lost event needed).
/// </summary>
[Test]
public void Reconcile_WhileUnfocused_ArmsSuppression()
{
FakeEnvironment environment = new FakeEnvironment { IsFocused = false, SuppressChangedViews = 2 };
PlayModeFocusSuppressionService service = environment.CreateService();

service.Reconcile();

Assert.That(environment.SuppressCallCount, Is.EqualTo(1));
Assert.That(environment.SuppressedFlag, Is.True);
}

/// <summary>
/// Verifies reconcile restores views while focused when a stale flag survived a restart.
/// </summary>
[Test]
public void Reconcile_WhileFocusedWithStaleFlag_RestoresAndClearsFlag()
{
FakeEnvironment environment = new FakeEnvironment
{
IsFocused = true,
SuppressedFlag = true,
RestoreChangedViews = 1
};
PlayModeFocusSuppressionService service = environment.CreateService();

service.Reconcile();

Assert.That(environment.RestoreCallCount, Is.EqualTo(1));
Assert.That(environment.SuppressedFlag, Is.False);
}

/// <summary>
/// Verifies reconcile is a no-op while focused with a clear flag (the steady state).
/// </summary>
[Test]
public void Reconcile_WhileFocusedWithFlagClear_DoesNothing()
{
FakeEnvironment environment = new FakeEnvironment { IsFocused = true };
PlayModeFocusSuppressionService service = environment.CreateService();

service.Reconcile();

Assert.That(environment.SuppressCallCount, Is.EqualTo(0));
Assert.That(environment.RestoreCallCount, Is.EqualTo(0));
Assert.That(environment.LoggedOperations, Is.Empty);
}

/// <summary>
/// Verifies the flag round-trips through the injected store across service instances,
/// simulating a domain reload between suppress and restore.
/// </summary>
[Test]
public void SuppressedFlag_PersistsAcrossServiceInstances_ViaInjectedStore()
{
FakeEnvironment environment = new FakeEnvironment { IsFocused = false, SuppressChangedViews = 1 };
PlayModeFocusSuppressionService firstService = environment.CreateService();
firstService.HandleFocusChanged(false);
Assert.That(environment.SuppressedFlag, Is.True);

environment.IsFocused = true;
environment.RestoreChangedViews = 1;
PlayModeFocusSuppressionService secondService = environment.CreateService();
secondService.HandleFocusChanged(true);

Assert.That(environment.RestoreCallCount, Is.EqualTo(1));
Assert.That(environment.SuppressedFlag, Is.False);
}

/// <summary>
/// Verifies repeated reconcile while unfocused logs the armed operation only when views actually changed.
/// </summary>
[Test]
public void Reconcile_RepeatedWhileUnfocused_LogsArmedOnlyOnActualChange()
{
FakeEnvironment environment = new FakeEnvironment { IsFocused = false, SuppressChangedViews = 1 };
PlayModeFocusSuppressionService service = environment.CreateService();

service.Reconcile();
environment.SuppressChangedViews = 0;
service.Reconcile();
service.Reconcile();

Assert.That(environment.SuppressCallCount, Is.EqualTo(3));
Assert.That(
environment.LoggedOperations.FindAll(operation => operation == "play_focus_suppress_armed"),
Has.Count.EqualTo(1));
}
}
}

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 @@ -22,5 +22,4 @@ Returns JSON with:

## Notes

- **Works even when Unity is busy** (compiling, domain reload, etc.)
- Useful before `uloop screenshot` to ensure the target window is visible
Loading
Loading