using System.Collections.Generic; using System.IO; using NUnit.Framework; using Unity.Pipeline.HotReload; using Unity.Pipeline.Runtime.Commands; using UnityEngine; namespace Unity.Pipeline.Tests.Editor { /// /// Top-level probe type woven by the HotReloadInPlace ILPostProcessor. Must be top-level (not /// nested) so the generated override can reference it by name from a separate assembly. /// public class InPlaceReloadProbe { public int baseline; public int hotValue; [HotReload] public void Compute() { baseline = 1; // original body } } /// /// Tests for the in-place hot reload workflow, covering all three layers: /// - compile-time IL weaving + runtime dispatch (the ILPostProcessor), /// - the Roslyn semantic-model transform + accessibility validation, /// - the full reload_file pipeline end to end. /// public class HotReloadInPlaceTests { // -------- weaving fixtures -------- public class WeaveTarget { public int ticks; public bool overrideRan; [HotReload] public void Tick() { ticks++; // original body } } public static class WeaveOverrides { [HotReloadOverrideMethod("WeaveTarget.Tick")] public static void Tick(WeaveTarget instance) { instance.overrideRan = true; } } private const string PublicSource = @" using UnityEngine; using Unity.Pipeline.HotReload; public class Demo : MonoBehaviour { public float speed = 1f; [HotReload] void Tick() { var dt = Time.deltaTime; transform.position += Vector3.right * speed * dt; } }"; private static Dictionary Bodies(params string[] names) { var d = new Dictionary(); foreach (var n in names) d[n] = ""; return d; } [SetUp] public void Setup() => HotReloadRegistry.ClearAllForTesting(); [TearDown] public void TearDown() => HotReloadRegistry.ClearAllForTesting(); // -------- weaving + dispatch -------- [Test] public void NoOverride_RunsOriginalBody() { var t = new WeaveTarget(); t.Tick(); Assert.AreEqual(1, t.ticks, "Original body should run when no override is registered"); Assert.IsFalse(t.overrideRan); } [Test] public void WithOverride_WovenDispatchInvokesOverride() { HotReloadRegistry.RegisterReloadableMethod( typeof(WeaveTarget).GetMethod(nameof(WeaveTarget.Tick)), new HotReloadWithOverridesAttribute()); var registered = HotReloadRegistry.RegisterMethodOverride( typeof(WeaveOverrides).GetMethod(nameof(WeaveOverrides.Tick)), new HotReloadOverrideMethodAttribute("WeaveTarget.Tick"), typeof(WeaveOverrides), out var reason); Assert.IsTrue(registered, reason); var t = new WeaveTarget(); t.Tick(); Assert.IsTrue(t.overrideRan, "Woven dispatch should have routed Tick() to the override"); Assert.AreEqual(0, t.ticks, "Original body should be skipped when the override runs"); } // -------- semantic transform + accessibility -------- [Test] public void Transform_QualifiesInstanceMembers_LeavesLocalsAndStatics() { var output = SourceCodeTransformer.TransformMethodBodies( Bodies("Tick"), "Demo", new Dictionary(), PublicSource); StringAssert.Contains("[HotReloadOverrideMethod(\"Demo.Tick\")]", output); StringAssert.Contains("public static void Tick(Demo instance", output); // Instance members are qualified. StringAssert.Contains("instance.transform", output); StringAssert.Contains("instance.speed", output); // Statics and locals are left alone. StringAssert.Contains("Time.deltaTime", output); StringAssert.Contains("Vector3.right", output); StringAssert.DoesNotContain("instance.dt", output); StringAssert.DoesNotContain("instance.Time", output); StringAssert.DoesNotContain("instance.Vector3", output); } [Test] public void Transform_WithLineDirectives_MapsBodyToOriginalFile() { // The body's opening brace sits on line 11 of PublicSource. var path = @"C:\proj\Demo.cs"; var output = SourceCodeTransformer.TransformMethodBodies( Bodies("Tick"), "Demo", new Dictionary(), PublicSource, emitLineDirectives: true, originalFilePath: path); // #line maps the body back to the original file (backslashes escaped for the literal), // bracketed by #line hidden so the generated scaffolding isn't attributed to user code. StringAssert.Contains("#line 11 \"C:\\\\proj\\\\Demo.cs\"", output); StringAssert.Contains("#line hidden", output); // The instance qualification still happens in this mode. StringAssert.Contains("instance.transform", output); StringAssert.Contains("instance.speed", output); } [Test] public void Transform_WithoutLineDirectives_EmitsNoLineDirectives() { var output = SourceCodeTransformer.TransformMethodBodies( Bodies("Tick"), "Demo", new Dictionary(), PublicSource); StringAssert.DoesNotContain("#line", output); } [Test] public void Validator_AllowsPublicMembers() { var result = AccessibilityValidator.ValidatePublicAccess(PublicSource, Bodies("Tick"), "Demo"); Assert.IsTrue(result.IsValid, result.GetFormattedErrorMessage()); } [Test] public void Validator_FlagsPrivateMember() { var source = @" using UnityEngine; using Unity.Pipeline.HotReload; public class Demo : MonoBehaviour { private float secretSpeed = 1f; [HotReload] void Tick() { transform.position += Vector3.right * secretSpeed * Time.deltaTime; } }"; var result = AccessibilityValidator.ValidatePublicAccess(source, Bodies("Tick"), "Demo"); Assert.IsFalse(result.IsValid); Assert.IsTrue(result.Violations.Exists(v => v.MemberName == "secretSpeed"), "Should flag the private field 'secretSpeed'"); StringAssert.DoesNotContain("transform", result.GetFormattedErrorMessage(), "Public/base members like 'transform' must not be flagged"); } // -------- end to end (full reload_file pipeline) -------- [Test] public void ReloadFileInPlace_EditedBody_AppliesViaWovenDispatch() { // Auto-discovery would do this at play start; register the target explicitly for the test. HotReloadRegistry.RegisterReloadableMethod( typeof(InPlaceReloadProbe).GetMethod(nameof(InPlaceReloadProbe.Compute)), new HotReloadWithOverridesAttribute()); // The "edited" source: Compute now writes hotValue instead of baseline. var editedSource = @" using Unity.Pipeline.HotReload; namespace Unity.Pipeline.Tests.Editor { public class InPlaceReloadProbe { public int baseline; public int hotValue; [HotReload] public void Compute() { hotValue = 99; } } }"; var path = Path.Combine(Application.temporaryCachePath, "InPlaceReloadProbe_edit.cs"); File.WriteAllText(path, editedSource); try { var response = HotReloadCommands.ReloadFile(path); Assert.IsTrue(response.Success, $"reload_file should succeed. Error: {response.ErrorDetails}"); var probe = new InPlaceReloadProbe(); probe.Compute(); Assert.AreEqual(99, probe.hotValue, "Edited [HotReload] body should run via the woven dispatch"); Assert.AreEqual(0, probe.baseline, "Original body should be skipped when the override runs"); } finally { if (File.Exists(path)) File.Delete(path); } } [Test] public void ReloadFileInPlace_WithPdb_StillAppliesViaWovenDispatch() { // Functional parity: the --pdb path (#line directives + portable PDB, unoptimized) must // still compile and dispatch exactly like the default path. HotReloadRegistry.RegisterReloadableMethod( typeof(InPlaceReloadProbe).GetMethod(nameof(InPlaceReloadProbe.Compute)), new HotReloadWithOverridesAttribute()); var editedSource = @" using Unity.Pipeline.HotReload; namespace Unity.Pipeline.Tests.Editor { public class InPlaceReloadProbe { public int baseline; public int hotValue; [HotReload] public void Compute() { hotValue = 99; } } }"; var path = Path.Combine(Application.temporaryCachePath, "InPlaceReloadProbe_pdb_edit.cs"); File.WriteAllText(path, editedSource); try { var response = HotReloadCommands.ReloadFile(path, pdb: true); Assert.IsTrue(response.Success, $"reload_file --pdb should succeed. Error: {response.ErrorDetails}"); var probe = new InPlaceReloadProbe(); probe.Compute(); Assert.AreEqual(99, probe.hotValue, "Edited body should run via woven dispatch even with --pdb"); Assert.AreEqual(0, probe.baseline, "Original body should be skipped when the override runs"); } finally { if (File.Exists(path)) File.Delete(path); } } } }