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
223 changes: 223 additions & 0 deletions Assets/Tests/Editor/EditorExecutionActivityTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
using System;
using System.Text.RegularExpressions;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;

using io.github.hatayama.UnityCliLoop.Infrastructure;

namespace io.github.hatayama.UnityCliLoop.Tests.Editor
{
/// <summary>
/// Verifies the registry that holds an operating-system activity while a command runs: every activity it
/// starts ends exactly once, it starts none after closing, and commands still run when none can start.
/// </summary>
public sealed class EditorExecutionActivityTests
{
private const string MissingEntryPointMessage = "native entry point missing in this test";

/// <summary>
/// Verifies one hold starts one activity and disposing the hold ends that same token.
/// </summary>
[Test]
public void Hold_ThenDispose_BeginsOneActivityAndEndsThatToken()
{
RecordingProcessActivityApi api = new RecordingProcessActivityApi();
EditorExecutionActivity activity = new EditorExecutionActivity(api);

IDisposable hold = activity.Hold();

Assert.That(api.BeginCount, Is.EqualTo(1));
Assert.That(api.LiveCount, Is.EqualTo(1));

hold.Dispose();

Assert.That(api.EndedTokens, Is.EqualTo(new[] { new IntPtr(1) }));
Assert.That(api.LiveCount, Is.EqualTo(0));
}

/// <summary>
/// Verifies disposing the same hold twice ends its activity only once.
/// </summary>
[Test]
public void Dispose_CalledTwice_EndsTheActivityOnce()
{
RecordingProcessActivityApi api = new RecordingProcessActivityApi();
EditorExecutionActivity activity = new EditorExecutionActivity(api);
IDisposable hold = activity.Hold();

hold.Dispose();
hold.Dispose();

Assert.That(api.EndedTokens, Is.EqualTo(new[] { new IntPtr(1) }));
}

/// <summary>
/// Verifies two overlapping holds each end their own token once, whichever is released first.
/// </summary>
[TestCase(0, 1)]
[TestCase(1, 0)]
public void Hold_TwiceOverlapping_EndsEachTokenOnceInEitherOrder(int releasedFirst, int releasedSecond)
{
RecordingProcessActivityApi api = new RecordingProcessActivityApi();
EditorExecutionActivity activity = new EditorExecutionActivity(api);
IDisposable[] holds = { activity.Hold(), activity.Hold() };

Assert.That(api.LiveCount, Is.EqualTo(2));

holds[releasedFirst].Dispose();
holds[releasedSecond].Dispose();

// Tokens count up from 1, so the hold at index i owns token i + 1.
Assert.That(
api.EndedTokens,
Is.EqualTo(new[] { new IntPtr(releasedFirst + 1), new IntPtr(releasedSecond + 1) }));
Assert.That(api.LiveCount, Is.EqualTo(0));
}

/// <summary>
/// Verifies closing ends every live activity, and disposing those holds afterwards ends nothing more.
/// </summary>
[Test]
public void ReleaseAllAndClose_EndsEveryLiveActivity_AndALaterDisposeEndsNothing()
{
RecordingProcessActivityApi api = new RecordingProcessActivityApi();
EditorExecutionActivity activity = new EditorExecutionActivity(api);
IDisposable first = activity.Hold();
IDisposable second = activity.Hold();

activity.ReleaseAllAndClose();

Assert.That(api.EndedTokens, Is.EquivalentTo(new[] { new IntPtr(1), new IntPtr(2) }));
Assert.That(api.LiveCount, Is.EqualTo(0));

first.Dispose();
second.Dispose();

Assert.That(api.EndedTokens.Count, Is.EqualTo(2));
}

/// <summary>
/// Verifies a hold requested after the registry closed starts no activity, while one requested
/// before closing did.
/// </summary>
[Test]
public void Hold_AfterReleaseAllAndClose_DoesNotBeginAnotherActivity()
{
RecordingProcessActivityApi api = new RecordingProcessActivityApi();
EditorExecutionActivity activity = new EditorExecutionActivity(api);
IDisposable earlyHold = activity.Hold();

Assert.That(api.BeginCount, Is.EqualTo(1));

activity.ReleaseAllAndClose();
IDisposable lateHold = activity.Hold();

Assert.That(api.BeginCount, Is.EqualTo(1));

lateHold.Dispose();
earlyHold.Dispose();
}

/// <summary>
/// Verifies a platform without the native entry points still lets commands run: the hold does not
/// throw, one warning is logged, and later holds do not try the native call again.
/// </summary>
[TestCase(typeof(EntryPointNotFoundException))]
[TestCase(typeof(DllNotFoundException))]
public void Hold_WhenThePlatformLacksTheNativeEntryPoints_RunsWithoutAnActivity_AndDoesNotTryAgain(
Type exceptionType)
{
RecordingProcessActivityApi api = new RecordingProcessActivityApi();
api.BeginException = (Exception)Activator.CreateInstance(exceptionType, MissingEntryPointMessage);
EditorExecutionActivity activity = new EditorExecutionActivity(api);
LogAssert.Expect(LogType.Warning, new Regex("throttling.*" + MissingEntryPointMessage));

IDisposable first = activity.Hold();
IDisposable second = activity.Hold();

Assert.That(first, Is.Not.Null);
Assert.That(second, Is.Not.Null);

first.Dispose();
second.Dispose();

Assert.That(api.BeginCount, Is.EqualTo(1));
Assert.That(api.EndedTokens, Is.Empty);
}

/// <summary>
/// Verifies a failing End reaches the caller and is never repeated for the same token, neither by
/// a second dispose nor by closing the registry.
/// </summary>
[Test]
public void Dispose_WhenEndThrows_DoesNotEndTheSameTokenAgain()
{
RecordingProcessActivityApi api = new RecordingProcessActivityApi();
EditorExecutionActivity activity = new EditorExecutionActivity(api);
IDisposable hold = activity.Hold();
api.ThrowOnNextEnd = true;

Assert.Throws<InvalidOperationException>(() => hold.Dispose());

hold.Dispose();
activity.ReleaseAllAndClose();

Assert.That(api.EndedTokens, Is.EqualTo(new[] { new IntPtr(1) }));
}

/// <summary>
/// Verifies a failing End while closing is logged instead of thrown, the other token is still ended,
/// and disposing the holds afterwards ends no token a second time.
/// </summary>
[Test]
public void ReleaseAllAndClose_WhenEndThrows_StillEndsTheOthers_AndEndsNoTokenTwice()
{
RecordingProcessActivityApi api = new RecordingProcessActivityApi();
EditorExecutionActivity activity = new EditorExecutionActivity(api);
IDisposable first = activity.Hold();
IDisposable second = activity.Hold();
api.ThrowOnNextEnd = true;
LogAssert.Expect(LogType.Exception, new Regex(RecordingProcessActivityApi.EndFailureMessage));

Assert.DoesNotThrow(() => activity.ReleaseAllAndClose());
Assert.That(api.EndedTokens, Is.EquivalentTo(new[] { new IntPtr(1), new IntPtr(2) }));

first.Dispose();
second.Dispose();

Assert.That(api.EndedTokens.Count, Is.EqualTo(2));
}

/// <summary>
/// Verifies that when the platform starts no activity, the hold is still a usable handle and
/// disposing it ends nothing.
/// </summary>
[Test]
public void Hold_WhenThePlatformReturnsNoToken_ReturnsAHandleThatEndsNothing()
{
RecordingProcessActivityApi api = new RecordingProcessActivityApi();
api.ReturnsNoToken = true;
EditorExecutionActivity activity = new EditorExecutionActivity(api);

IDisposable hold = activity.Hold();

Assert.That(hold, Is.Not.Null);

hold.Dispose();

Assert.That(api.EndedTokens, Is.Empty);
}

/// <summary>
/// Verifies the API used off macOS never starts an activity.
/// </summary>
[Test]
public void InertProcessActivityApi_Begin_ReturnsNoToken()
{
InertProcessActivityApi api = new InertProcessActivityApi();

Assert.That(api.Begin("test reason"), Is.EqualTo(IntPtr.Zero));
}
}
}
11 changes: 11 additions & 0 deletions Assets/Tests/Editor/EditorExecutionActivityTests.cs.meta

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 @@ -658,7 +658,7 @@ private static UnityCliLoopToolRegistrarService CreateRegistrarServiceWithToolSe

private static JsonRpcRequestProcessor CreateProcessor(UnityCliLoopToolRegistrarService service)
{
UnityCliLoopExecutionRouter executionRouter = new(service);
UnityCliLoopExecutionRouter executionRouter = new(service, new EditorExecutionActivity(new InertProcessActivityApi()));
return new JsonRpcRequestProcessor(executionRouter);
}

Expand Down
2 changes: 1 addition & 1 deletion Assets/Tests/Editor/JsonRpcRequestProcessorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ private static JsonRpcRequestProcessor CreateProcessor()
new AllToolsEnabledSettingsPort(),
new UnityCliLoopToolExecutionService(new IdleEditorRuntimeStatePort()),
() => Array.Empty<IUnityCliLoopTool>());
return new JsonRpcRequestProcessor(new UnityCliLoopExecutionRouter(registrarService));
return new JsonRpcRequestProcessor(new UnityCliLoopExecutionRouter(registrarService, new EditorExecutionActivity(new InertProcessActivityApi())));
}

private sealed class AllToolsEnabledSettingsPort : IToolSettingsPort
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ public async Task ProcessRequest_WhenProtocolVersionIsTooOld_ProducesFrozenMisma

private static JsonRpcRequestProcessor CreateProcessor(UnityCliLoopToolRegistrarService service)
{
UnityCliLoopExecutionRouter executionRouter = new(service);
UnityCliLoopExecutionRouter executionRouter = new(service, new EditorExecutionActivity(new InertProcessActivityApi()));
return new JsonRpcRequestProcessor(executionRouter);
}

Expand Down
49 changes: 49 additions & 0 deletions Assets/Tests/Editor/MacProcessActivityApiTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System;
using NUnit.Framework;
using UnityEngine;

using io.github.hatayama.UnityCliLoop.Infrastructure;

namespace io.github.hatayama.UnityCliLoop.Tests.Editor
{
/// <summary>
/// Verifies the macOS process activity adapter against the real operating system calls.
/// </summary>
public sealed class MacProcessActivityApiTests
{
/// <summary>
/// Verifies that on macOS the real calls start an activity with a non-zero token and end it without
/// throwing, three times in a row. Other platforms skip the test.
/// </summary>
[Test]
public void BeginThenEnd_OnMacOS_ReturnsATokenAndDoesNotThrow()
{
// Why a runtime check instead of a platform attribute: this repository has no precedent for
// narrowing EditMode tests by platform attribute, and how it behaves there is unverified.
if (UnityEngine.Application.platform != RuntimePlatform.OSXEditor)
{
Assert.Ignore("macOS only");
}

MacProcessActivityApi api = new MacProcessActivityApi();
for (int attempt = 0; attempt < 3; attempt++)
{
IntPtr token = api.Begin("Unity CLI Loop activity test");

Assert.That(token, Is.Not.EqualTo(IntPtr.Zero));
Assert.DoesNotThrow(() => api.End(token));
}
}

/// <summary>
/// Verifies ending with no token is rejected before any native call, so it holds on every platform.
/// </summary>
[Test]
public void End_WithNoToken_Throws()
{
MacProcessActivityApi api = new MacProcessActivityApi();

Assert.Throws<ArgumentException>(() => api.End(IntPtr.Zero));
}
}
}
11 changes: 11 additions & 0 deletions Assets/Tests/Editor/MacProcessActivityApiTests.cs.meta

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

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

using io.github.hatayama.UnityCliLoop.Infrastructure;

namespace io.github.hatayama.UnityCliLoop.Tests.Editor
{
/// <summary>
/// Test double for the operating-system activity calls. It records Begin and End calls,
/// hands out tokens that count up from 1, and can be told to fail either call.
/// </summary>
internal sealed class RecordingProcessActivityApi : IProcessActivityApi
{
internal const string EndFailureMessage = "End failed in this test";

private readonly HashSet<IntPtr> _liveTokens = new HashSet<IntPtr>();
private readonly List<IntPtr> _endedTokens = new List<IntPtr>();
private int _lastToken;

internal int BeginCount { get; private set; }

internal IReadOnlyList<IntPtr> EndedTokens => _endedTokens;

internal int LiveCount => _liveTokens.Count;

internal bool ReturnsNoToken { get; set; }

internal Exception BeginException { get; set; }

internal bool ThrowOnNextEnd { get; set; }

public IntPtr Begin(string reason)
{
BeginCount++;
if (BeginException != null)
{
throw BeginException;
}

if (ReturnsNoToken)
{
return IntPtr.Zero;
}

_lastToken++;
IntPtr token = new IntPtr(_lastToken);
_liveTokens.Add(token);
return token;
}

public void End(IntPtr token)
{
_endedTokens.Add(token);
if (ThrowOnNextEnd)
{
ThrowOnNextEnd = false;
throw new InvalidOperationException(EndFailureMessage);
}

_liveTokens.Remove(token);
}
}
}
Loading
Loading