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
160 changes: 160 additions & 0 deletions Assets/Tests/Editor/HotReload/HotReloadCompilationAssembliesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
using System;
using System.Collections.Generic;
using System.Reflection;

using NUnit.Framework;

using UnityEditor;
using UnityEditor.Compilation;

using io.github.hatayama.UnityCliLoop.FirstPartyTools;

using UnityCompilationAssembly = UnityEditor.Compilation.Assembly;

namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload
{
/// <summary>
/// EditMode coverage for the per-domain compilation assembly list: one shared memo, dropped by the
/// Unity events that can change the list.
/// </summary>
public sealed class HotReloadCompilationAssembliesTests
{
// Why string names: the handlers are private to the production class; a rename fails these tests
// instead of silently passing them.
private const string CompilationStartedHandlerName = "OnCompilationStarted";
private const string ProjectChangedHandlerName = "OnProjectChanged";

/// <summary>
/// Verifies that the static constructor subscribed the memo's invalidation to compile start and
/// project change.
/// </summary>
[Test]
public void StaticConstructor_SubscribesInvalidationToCompilationStartedAndProjectChanged()
{
HotReloadCompilationAssemblies.Current();

Assert.That(
StaticEventHasHandler(typeof(CompilationPipeline), CompilationStartedHandlerName),
Is.True,
"OnCompilationStarted must be subscribed on CompilationPipeline.");
Assert.That(
StaticEventHasHandler(typeof(EditorApplication), ProjectChangedHandlerName),
Is.True,
"OnProjectChanged must be subscribed on EditorApplication.");
}

/// <summary>
/// Verifies that two calls share one kept list instead of asking Unity each time.
/// </summary>
[Test]
public void Current_CalledTwice_ReturnsSameInstance()
{
IReadOnlyList<UnityCompilationAssembly> first = HotReloadCompilationAssemblies.Current();
IReadOnlyList<UnityCompilationAssembly> second = HotReloadCompilationAssemblies.Current();

Assert.That(first, Is.Not.Empty);
Assert.That(second, Is.SameAs(first));
}

// Why: CompilationPipeline stores handlers on Delegate fields, but EditorApplication.projectChanged
// lives on EventWithPerformanceTracker. A Delegate-only scan cannot see it.
private static bool StaticEventHasHandler(Type eventOwner, string handlerName)
{
FieldInfo[] fields = eventOwner.GetFields(
BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
for (int index = 0; index < fields.Length; index++)
{
object value = fields[index].GetValue(null);
if (ContainsProductionHandler(value, handlerName))
{
return true;
}
}

return false;
}

private static bool ContainsProductionHandler(object source, string handlerName)
{
if (source == null)
{
return false;
}

Delegate current = source as Delegate;
if (current != null)
{
return InvocationListContains(current, handlerName);
}

return EnumeratorContainsHandler(source, handlerName);
}

private static bool InvocationListContains(Delegate current, string handlerName)
{
Delegate[] listeners = current.GetInvocationList();
for (int listenerIndex = 0; listenerIndex < listeners.Length; listenerIndex++)
{
if (IsProductionHandler(listeners[listenerIndex], handlerName))
{
return true;
}
}

return false;
}

private static bool EnumeratorContainsHandler(object source, string handlerName)
{
string typeName = source.GetType().Name;
if (typeName.IndexOf("EventWithPerformanceTracker", StringComparison.Ordinal) < 0)
{
return false;
}

MethodInfo getEnumerator = source.GetType().GetMethod(
"GetEnumerator",
BindingFlags.Instance | BindingFlags.Public);
if (getEnumerator == null || getEnumerator.GetParameters().Length != 0)
{
return false;
}

object enumerator = getEnumerator.Invoke(source, null);
if (enumerator == null)
{
return false;
}

MethodInfo moveNext = enumerator.GetType().GetMethod("MoveNext");
PropertyInfo currentProperty = enumerator.GetType().GetProperty("Current");
if (moveNext == null || currentProperty == null)
{
return false;
}

while ((bool)moveNext.Invoke(enumerator, null))
{
Delegate listener = currentProperty.GetValue(enumerator) as Delegate;
if (IsProductionHandler(listener, handlerName))
{
return true;
}
}

return false;
}

private static bool IsProductionHandler(Delegate listener, string handlerName)
{
if (listener == null)
{
return false;
}

MethodInfo listenerMethod = listener.Method;
return listenerMethod.DeclaringType == typeof(HotReloadCompilationAssemblies)
&& listenerMethod.Name == handlerName;
}
}
}

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,180 @@
using System;
using System.Collections.Generic;

using NUnit.Framework;

using UnityEditor.Compilation;

using io.github.hatayama.UnityCliLoop.FirstPartyTools;

using UnityCompilationAssembly = UnityEditor.Compilation.Assembly;

namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload
{
/// <summary>
/// EditMode coverage for the per-domain memo of the compilation assembly list: when it asks Unity,
/// when it reuses the last answer, and why an empty answer is never kept.
/// </summary>
public sealed class HotReloadCompilationAssemblyCacheTests
{
private int _fetchCount;
private Queue<UnityCompilationAssembly[]> _answers;

[SetUp]
public void SetUp()
{
_fetchCount = 0;
_answers = new Queue<UnityCompilationAssembly[]>();
}

/// <summary>
/// Verifies that a second Current() reuses the first answer without asking Unity again.
/// </summary>
[Test]
public void Current_CalledTwice_FetchesOnceAndReturnsSameInstance()
{
_answers.Enqueue(new[] { CreateAssembly("First") });
HotReloadCompilationAssemblyCache cache = CreateCache();

IReadOnlyList<UnityCompilationAssembly> first = cache.Current();
IReadOnlyList<UnityCompilationAssembly> second = cache.Current();

Assert.That(_fetchCount, Is.EqualTo(1));
Assert.That(second, Is.SameAs(first));
Assert.That(cache.IsFilled, Is.True);
}

/// <summary>
/// Verifies that Invalidate() drops the memo so the next Current() asks Unity for a new list.
/// </summary>
[Test]
public void Current_AfterInvalidate_FetchesAgainAndReturnsNewAnswer()
{
UnityCompilationAssembly[] firstAnswer = { CreateAssembly("First") };
UnityCompilationAssembly[] secondAnswer = { CreateAssembly("Second") };
_answers.Enqueue(firstAnswer);
_answers.Enqueue(secondAnswer);
HotReloadCompilationAssemblyCache cache = CreateCache();
cache.Current();

cache.Invalidate();
IReadOnlyList<UnityCompilationAssembly> afterInvalidate = cache.Current();

Assert.That(_fetchCount, Is.EqualTo(2));
Assert.That(afterInvalidate, Is.SameAs(secondAnswer));
}

/// <summary>
/// Verifies that an empty answer, which Unity gives while a compile is in flight, is not kept.
/// </summary>
[Test]
public void Current_EmptyAnswer_IsNotMemoized()
{
_answers.Enqueue(Array.Empty<UnityCompilationAssembly>());
_answers.Enqueue(Array.Empty<UnityCompilationAssembly>());
HotReloadCompilationAssemblyCache cache = CreateCache();

IReadOnlyList<UnityCompilationAssembly> first = cache.Current();
bool filledAfterEmpty = cache.IsFilled;
cache.Current();

Assert.That(first, Is.Empty);
Assert.That(filledAfterEmpty, Is.False);
Assert.That(_fetchCount, Is.EqualTo(2));
}

/// <summary>
/// Verifies that the first non-empty answer after an empty one is kept.
/// </summary>
[Test]
public void Current_NonEmptyAfterEmpty_IsMemoized()
{
UnityCompilationAssembly[] nonEmpty = { CreateAssembly("First") };
_answers.Enqueue(Array.Empty<UnityCompilationAssembly>());
_answers.Enqueue(nonEmpty);
HotReloadCompilationAssemblyCache cache = CreateCache();

cache.Current();
cache.Current();
IReadOnlyList<UnityCompilationAssembly> third = cache.Current();

Assert.That(_fetchCount, Is.EqualTo(2));
Assert.That(third, Is.SameAs(nonEmpty));
}

/// <summary>
/// Verifies that FindByName returns the assembly with that name and asks Unity only once across lookups.
/// </summary>
[Test]
public void FindByName_KnownName_ReturnsAssemblyAndFetchesOnce()
{
UnityCompilationAssembly target = CreateAssembly("Second");
_answers.Enqueue(new[] { CreateAssembly("First"), target });
HotReloadCompilationAssemblyCache cache = CreateCache();

UnityCompilationAssembly first = cache.FindByName("Second");
UnityCompilationAssembly second = cache.FindByName("Second");

Assert.That(first, Is.SameAs(target));
Assert.That(second, Is.SameAs(target));
Assert.That(_fetchCount, Is.EqualTo(1));
}

/// <summary>
/// Verifies that FindByName matches the whole name with case, not a prefix or a case-folded name,
/// and that it looks at the first assembly too.
/// </summary>
[Test]
public void FindByName_SimilarNames_ReturnsExactMatchOnly()
{
UnityCompilationAssembly lowerCase = CreateAssembly("foo");
UnityCompilationAssembly prefixed = CreateAssembly("Foo.Editor");
UnityCompilationAssembly exact = CreateAssembly("Foo");
_answers.Enqueue(new[] { lowerCase, prefixed, exact });
HotReloadCompilationAssemblyCache cache = CreateCache();

UnityCompilationAssembly foundUpper = cache.FindByName("Foo");
UnityCompilationAssembly foundLower = cache.FindByName("foo");

Assert.That(foundUpper, Is.SameAs(exact));
Assert.That(foundLower, Is.SameAs(lowerCase));
}

/// <summary>
/// Verifies that FindByName returns null for a name no compilation assembly has.
/// </summary>
[Test]
public void FindByName_UnknownName_ReturnsNull()
{
_answers.Enqueue(new[] { CreateAssembly("First") });
HotReloadCompilationAssemblyCache cache = CreateCache();

UnityCompilationAssembly found = cache.FindByName("Missing");

Assert.That(found, Is.Null);
}

private HotReloadCompilationAssemblyCache CreateCache()
{
return new HotReloadCompilationAssemblyCache(Fetch);
}

private UnityCompilationAssembly[] Fetch()
{
_fetchCount++;
return _answers.Dequeue();
}

private static UnityCompilationAssembly CreateAssembly(string assemblyName)
{
return new UnityCompilationAssembly(
assemblyName,
HotReloadConstants.ScriptAssembliesRelativeDirectory + "/" + assemblyName + HotReloadConstants.CompiledAssemblyExtension,
Array.Empty<string>(),
Array.Empty<string>(),
Array.Empty<UnityCompilationAssembly>(),
Array.Empty<string>(),
AssemblyFlags.EditorAssembly);
}
}
}

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 @@ -174,7 +174,7 @@ private static Dictionary<string, string[]> GetReferencedDllFileNamesByAssembly(
}

Dictionary<string, string[]> graph = new Dictionary<string, string[]>(StringComparer.Ordinal);
foreach (UnityCompilationAssembly assembly in CompilationPipeline.GetAssemblies())
foreach (UnityCompilationAssembly assembly in HotReloadCompilationAssemblies.Current())
{
graph[assembly.name] = CollectReferenceFileNames(assembly.allReferences);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
using System.Collections.Generic;
using System.IO;

using UnityEditor.Compilation;

using UnityEngine;

using UnityCompilationAssembly = UnityEditor.Compilation.Assembly;
Expand Down Expand Up @@ -64,7 +62,7 @@ internal static HotReloadChangedFileAggregationResult Detect()
string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
List<HotReloadSnapshotAssembly> snapshotAssemblies = CollectSnapshotAssemblies(
projectRoot,
CompilationPipeline.GetAssemblies());
HotReloadCompilationAssemblies.Current());
return DetectFromSnapshotDirectories(projectRoot, snapshotAssemblies);
}

Expand Down
Loading
Loading