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 { /// /// Tests for HotReloadCompiler to verify refactoring, deadlock fix, and core functionality. /// Tests the complete workflow: file reading → compilation → method registration. /// 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"); } } }