362 lines
16 KiB
C#
362 lines
16 KiB
C#
using System.Collections;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using NUnit.Framework;
|
|
using Unity.Pipeline.Compilation;
|
|
using Unity.Pipeline.HotReload;
|
|
using UnityEngine;
|
|
using UnityEngine.TestTools;
|
|
|
|
namespace Unity.Pipeline.Tests.Editor
|
|
{
|
|
/// <summary>
|
|
/// Tests for HotReloadCompiler to verify refactoring, deadlock fix, and core functionality.
|
|
/// Tests the complete workflow: file reading → compilation → method registration.
|
|
/// </summary>
|
|
public class HotReloadCompilerTests
|
|
{
|
|
private string testHotReloadDir;
|
|
private string validHotReloadFile;
|
|
private string invalidHotReloadFile;
|
|
private string emptyHotReloadFile;
|
|
private int m_MainThreadId;
|
|
|
|
[SetUp]
|
|
public void SetUp()
|
|
{
|
|
// Write test source files OUTSIDE Assets/ (project-root Temp/, which Unity ignores) so
|
|
// they are never imported/compiled by the editor — importing a .cs under Assets/ triggers
|
|
// a domain reload that tears down the live pipeline server. Absolute paths are passed to
|
|
// the compiler, whose GetHotReloadPath returns rooted paths as-is (no Assets/ prefix).
|
|
testHotReloadDir = Path.GetFullPath(Path.Combine("Temp", "PipelineHotReloadTests", "CompilerTests"));
|
|
Directory.CreateDirectory(testHotReloadDir);
|
|
|
|
validHotReloadFile = Path.Combine(testHotReloadDir, "ValidTest.cs");
|
|
invalidHotReloadFile = Path.Combine(testHotReloadDir, "InvalidTest.cs");
|
|
emptyHotReloadFile = Path.Combine(testHotReloadDir, "EmptyTest.cs");
|
|
|
|
// Create valid hot reload file with both reloadable target and hot reload override
|
|
File.WriteAllText(validHotReloadFile, @"
|
|
using Unity.Pipeline.HotReload;
|
|
using UnityEngine;
|
|
|
|
// Target class with reloadable method
|
|
public class TestClass
|
|
{
|
|
public int value = 42;
|
|
|
|
[HotReloadWithOverrides(Id = ""TestClass.TestMethod"")]
|
|
public void TestMethod()
|
|
{
|
|
value = 123;
|
|
}
|
|
}
|
|
|
|
// Hot reload override
|
|
public static class ValidTestHotReload
|
|
{
|
|
[HotReloadOverrideMethod(""TestClass.TestMethod"")]
|
|
public static void TestMethod(TestClass instance)
|
|
{
|
|
instance.value = 999;
|
|
Debug.Log(""Hot reload method executed"");
|
|
}
|
|
}");
|
|
|
|
// Create invalid hot reload file (syntax error)
|
|
File.WriteAllText(invalidHotReloadFile, @"
|
|
using Unity.Pipeline.HotReload;
|
|
|
|
// Need to define TestClass since we're not wrapping code anymore
|
|
public class TestClass
|
|
{
|
|
public int value = 42;
|
|
|
|
[HotReloadWithOverrides(Id = ""TestClass.TestMethod"")]
|
|
public void TestMethod()
|
|
{
|
|
value = 123;
|
|
}
|
|
}
|
|
|
|
public static class InvalidTestHotReload
|
|
{
|
|
[HotReloadOverrideMethod(""TestClass.TestMethod"")]
|
|
public static void TestMethod(TestClass instance)
|
|
{
|
|
instance.value = 999 +; // Syntax error
|
|
}
|
|
}");
|
|
|
|
// Create empty file
|
|
File.WriteAllText(emptyHotReloadFile, "");
|
|
|
|
// Clear hot reload state
|
|
HotReloadRegistry.ClearAllForTesting();
|
|
|
|
// Capture the main thread so tests can assert thread context.
|
|
m_MainThreadId = Thread.CurrentThread.ManagedThreadId;
|
|
}
|
|
|
|
[TearDown]
|
|
public void TearDown()
|
|
{
|
|
// Clean up test files
|
|
if (Directory.Exists(testHotReloadDir))
|
|
{
|
|
Directory.Delete(testHotReloadDir, true);
|
|
}
|
|
|
|
// Clean up hot reload state and temp files
|
|
HotReloadRegistry.ClearAllForTesting();
|
|
HotReloadCompiler.CleanupHotReloadDlls();
|
|
}
|
|
|
|
[Test]
|
|
public void CompileAndApplyAsync_FromMainThread_RunsSynchronously()
|
|
{
|
|
// Arrange - We're on main thread
|
|
Assert.AreEqual(m_MainThreadId, Thread.CurrentThread.ManagedThreadId, "Test should run on main thread");
|
|
|
|
// Act - This should not deadlock (the main fix being tested)
|
|
var task = HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
|
|
|
|
// Assert - Should complete synchronously without blocking
|
|
Assert.IsTrue(task.IsCompleted, "Should complete synchronously on main thread to avoid deadlock");
|
|
|
|
var result = task.Result;
|
|
Assert.IsTrue(result.IsSuccess, $"Valid hot reload should succeed. Error: {result.Error}");
|
|
Assert.IsNotEmpty(result.AssemblyName, "Should have assembly name");
|
|
Assert.Greater(result.ExecutionTimeMs, 0, "Should record execution time");
|
|
}
|
|
|
|
[Test]
|
|
public async Task CompileAndApplyAsync_FromBackgroundThread_UsesAsyncPath()
|
|
{
|
|
// Arrange - Track thread IDs
|
|
var mainThreadId = Thread.CurrentThread.ManagedThreadId;
|
|
var backgroundThreadId = -1;
|
|
HotReloadCompileResult result = null;
|
|
|
|
// Act - Run from background thread
|
|
await Task.Run(async () =>
|
|
{
|
|
backgroundThreadId = Thread.CurrentThread.ManagedThreadId;
|
|
Assert.AreNotEqual(mainThreadId, backgroundThreadId, "Should be on different thread");
|
|
Assert.AreNotEqual(m_MainThreadId, Thread.CurrentThread.ManagedThreadId, "Should not be on main thread");
|
|
|
|
result = await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
|
|
});
|
|
|
|
// Assert
|
|
Assert.IsNotNull(result, "Should get result");
|
|
Assert.IsTrue(result.IsSuccess, $"Background compilation should succeed. Error: {result.Error}");
|
|
Assert.IsNotEmpty(result.AssemblyName, "Should have assembly name");
|
|
}
|
|
|
|
[Test]
|
|
public async Task CompileAndApplyAsync_ValidFile_RegistersHotReloadMethods()
|
|
{
|
|
// Get initial stats for comparison
|
|
var initialStats = HotReloadRegistry.GetStats();
|
|
|
|
// Act
|
|
var result = await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
|
|
|
|
// Assert compilation success
|
|
Assert.IsTrue(result.IsSuccess, $"Should succeed. Error: {result.Error}");
|
|
|
|
Assert.Greater(result.RegisteredMethods.Count, 0, "Should register at least one method");
|
|
Assert.Contains("TestClass.TestMethod", result.RegisteredMethods, "Should register the specified target method");
|
|
|
|
// Get final stats and compare
|
|
var finalStats = HotReloadRegistry.GetStats();
|
|
|
|
// Verify method was registered in registry
|
|
Assert.Greater(finalStats.ActiveOverrideCount, initialStats.ActiveOverrideCount, $"Active override count should increase from {initialStats.ActiveOverrideCount} to more than {initialStats.ActiveOverrideCount}");
|
|
}
|
|
|
|
[Test]
|
|
public async Task CompileAndApplyAsync_InvalidSyntax_ReturnsCompilationError()
|
|
{
|
|
// Act
|
|
var result = await HotReloadCompiler.CompileAndApplyAsync(invalidHotReloadFile);
|
|
|
|
// Assert
|
|
Assert.IsFalse(result.IsSuccess, "Invalid syntax should fail compilation");
|
|
Assert.AreEqual("Compilation Failed", result.Error, "Should return compilation failed error");
|
|
Assert.IsNotNull(result.Diagnostics, "Should have diagnostics");
|
|
Assert.Greater(result.Diagnostics.Count, 0, "Should have compilation diagnostics");
|
|
}
|
|
|
|
[Test]
|
|
public async Task CompileAndApplyAsync_EmptyFile_ReturnsEmptyFileError()
|
|
{
|
|
// Act
|
|
var result = await HotReloadCompiler.CompileAndApplyAsync(emptyHotReloadFile);
|
|
|
|
// Assert
|
|
Assert.IsFalse(result.IsSuccess, "Empty file should fail");
|
|
Assert.AreEqual("Empty File", result.Error, "Should return empty file error");
|
|
Assert.That(result.ErrorDetails, Does.Contain("empty"), "Should mention file is empty");
|
|
}
|
|
|
|
[Test]
|
|
public async Task CompileAndApplyAsync_FileNotFound_ReturnsFileNotFoundError()
|
|
{
|
|
// Act
|
|
var result = await HotReloadCompiler.CompileAndApplyAsync("NonExistentFile.cs");
|
|
|
|
// Assert
|
|
Assert.IsFalse(result.IsSuccess, "Non-existent file should fail");
|
|
Assert.AreEqual("File Not Found", result.Error, "Should return file not found error");
|
|
Assert.That(result.ErrorDetails, Does.Contain("not found"), "Should mention file not found");
|
|
}
|
|
|
|
[Test]
|
|
public async Task CompileAndApplyAsync_VersionTracking_GeneratesIncrementingVersions()
|
|
{
|
|
// Expected errors: signature mismatches for versions 2+ due to cross-assembly type incompatibility
|
|
LogAssert.Expect(LogType.Error, "HotReload: Signature mismatch for 'TestClass.TestMethod'. Override method must have instance parameter as first argument.");
|
|
LogAssert.Expect(LogType.Error, "HotReload: Signature mismatch for 'TestClass.TestMethod'. Override method must have instance parameter as first argument.");
|
|
|
|
// Act - Compile the same file multiple times
|
|
var result1 = await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
|
|
var result2 = await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
|
|
var result3 = await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
|
|
|
|
// Assert - Each compilation should have incrementing version numbers
|
|
Assert.IsTrue(result1.IsSuccess, "First compilation should succeed");
|
|
Assert.IsTrue(result2.IsSuccess, "Second compilation should succeed");
|
|
Assert.IsTrue(result3.IsSuccess, "Third compilation should succeed");
|
|
|
|
// Assembly names should contain version numbers
|
|
Assert.That(result1.AssemblyName, Does.Contain("_001"), "First version should be 001");
|
|
Assert.That(result2.AssemblyName, Does.Contain("_002"), "Second version should be 002");
|
|
Assert.That(result3.AssemblyName, Does.Contain("_003"), "Third version should be 003");
|
|
}
|
|
|
|
[Test]
|
|
public void CleanupHotReloadDlls_WithNoFiles_SucceedsGracefully()
|
|
{
|
|
// Act
|
|
var result = HotReloadCompiler.CleanupHotReloadDlls();
|
|
|
|
// Assert
|
|
Assert.IsTrue(result.Success, "Cleanup should succeed even when no files exist");
|
|
Assert.AreEqual(0, result.DeletedFiles.Count, "Should not delete any files");
|
|
Assert.That(result.Message, Does.Contain("No hot reload temp directory").Or.Contain("0 old DLL versions"));
|
|
}
|
|
|
|
[Test]
|
|
public async Task CleanupHotReloadDlls_AfterCompilations_CleansUpOldVersions()
|
|
{
|
|
// Expected errors: signature mismatches for versions 2+ due to cross-assembly type incompatibility
|
|
LogAssert.Expect(LogType.Error, "HotReload: Signature mismatch for 'TestClass.TestMethod'. Override method must have instance parameter as first argument.");
|
|
LogAssert.Expect(LogType.Error, "HotReload: Signature mismatch for 'TestClass.TestMethod'. Override method must have instance parameter as first argument.");
|
|
|
|
// Arrange - Create multiple versions
|
|
await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
|
|
await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
|
|
await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
|
|
|
|
// Act - Cleanup
|
|
var cleanupResult = HotReloadCompiler.CleanupHotReloadDlls();
|
|
|
|
// Assert
|
|
Assert.IsTrue(cleanupResult.Success, "Cleanup should succeed");
|
|
Assert.GreaterOrEqual(cleanupResult.ExecutionTimeMs, 0, "Should record execution time (0ms is acceptable for fast cleanup)");
|
|
|
|
// Registry should be cleared
|
|
var stats = HotReloadRegistry.GetStats();
|
|
Assert.AreEqual(0, stats.ActiveOverrideCount, "Registry should be cleared after cleanup");
|
|
}
|
|
|
|
[Test]
|
|
public async Task CompileAndApplyAsync_UsesRoslynCompilationService_Integration()
|
|
{
|
|
// This test verifies that HotReloadCompiler properly integrates with RoslynCompilationService
|
|
|
|
// Act
|
|
var result = await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
|
|
|
|
// Assert - Verify it uses the shared service (indirect verification through successful compilation)
|
|
Assert.IsTrue(result.IsSuccess, "Should successfully compile using shared RoslynCompilationService");
|
|
Assert.IsNotEmpty(result.AssemblyName, "Should generate assembly name");
|
|
|
|
// The fact that it compiles successfully with the same diagnostics format as EvalCodeCompiler
|
|
// indicates it's using the shared RoslynCompilationService
|
|
if (!result.IsSuccess && result.Diagnostics?.Any() == true)
|
|
{
|
|
// If there were diagnostics, they should be in the same format as RoslynCompilationService
|
|
var diagnostic = result.Diagnostics.First();
|
|
Assert.IsNotEmpty(diagnostic, "Diagnostic should not be empty");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public async Task CompileAndApplyAsync_ResolvesPaths_AbsoluteAndRelative()
|
|
{
|
|
// Absolute paths are used as-is.
|
|
var result = await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
|
|
Assert.IsTrue(result.IsSuccess, $"Should find file by absolute path. Error: {result.Error}");
|
|
|
|
// Relative paths resolve against the project root (no special "HotReload" folder).
|
|
var result2 = await HotReloadCompiler.CompileAndApplyAsync("DoesNotExist.cs");
|
|
Assert.IsFalse(result2.IsSuccess, "Should fail for non-existent file");
|
|
Assert.That(result2.ErrorDetails, Does.Contain("DoesNotExist.cs"),
|
|
"Error should reference the resolved path");
|
|
Assert.That(result2.ErrorDetails, Does.Not.Contain("HotReload\\DoesNotExist.cs"),
|
|
"Relative paths should no longer be rooted under a HotReload folder");
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator CompileAndApplyAsync_PerformanceComparison_LogsTiming()
|
|
{
|
|
// Expected error: signature mismatch for second compilation due to cross-assembly type incompatibility
|
|
LogAssert.Expect(LogType.Error, "HotReload: Signature mismatch for 'TestClass.TestMethod'. Override method must have instance parameter as first argument.");
|
|
|
|
// Test to observe performance differences between main thread and background execution
|
|
var filename = validHotReloadFile;
|
|
|
|
// Main thread execution
|
|
var sw1 = System.Diagnostics.Stopwatch.StartNew();
|
|
var mainThreadTask = HotReloadCompiler.CompileAndApplyAsync(filename);
|
|
yield return new WaitUntil(() => mainThreadTask.IsCompleted);
|
|
sw1.Stop();
|
|
var result1 = mainThreadTask.Result;
|
|
|
|
Assert.IsTrue(result1.IsSuccess, "Main thread execution should succeed");
|
|
|
|
// Background thread execution
|
|
var sw2 = System.Diagnostics.Stopwatch.StartNew();
|
|
HotReloadCompileResult result2 = null;
|
|
var backgroundTask = Task.Run(async () =>
|
|
{
|
|
result2 = await HotReloadCompiler.CompileAndApplyAsync(filename);
|
|
});
|
|
|
|
yield return new WaitUntil(() => backgroundTask.IsCompleted);
|
|
sw2.Stop();
|
|
|
|
Assert.IsNotNull(result2, "Background execution should complete");
|
|
Assert.IsTrue(result2.IsSuccess, "Background execution should succeed");
|
|
}
|
|
|
|
[Test]
|
|
public void CompileAndApplyOnMainThread_DirectCall_WorksSynchronously()
|
|
{
|
|
// Test the direct synchronous method (used internally for main thread detection)
|
|
|
|
// Act - Call the synchronous version directly
|
|
var result = HotReloadCompiler.CompileAndApplyOnMainThread(validHotReloadFile);
|
|
|
|
// Assert - Should complete synchronously
|
|
Assert.IsNotNull(result, "Should return result immediately");
|
|
Assert.IsTrue(result.IsSuccess, $"Direct synchronous call should succeed. Error: {result.Error}");
|
|
Assert.Greater(result.ExecutionTimeMs, 0, "Should record execution time");
|
|
}
|
|
}
|
|
} |