Files
AIGame/AIClient/Packages/com.unity.pipeline/Tests~/Editor/CodeReloadInPlaceTests.cs
T

1007 lines
38 KiB
C#

using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using NUnit.Framework;
using Unity.Pipeline.CodeReload;
using Unity.Pipeline.Runtime.Commands;
using UnityEngine;
using UnityEngine.TestTools;
namespace FakeReload
{
/// <summary>
/// An unrelated attribute that happens to share the simple name <c>CodeReloadAttribute</c>.
/// The IL weaver must match the attribute's full name, so methods tagged with this never get
/// a dispatch prologue (runtime discovery only honors the real Unity.Pipeline.CodeReload type).
/// Lives in its own namespace so it cannot shadow the real attribute elsewhere in this file.
/// </summary>
public class CodeReloadAttribute : System.Attribute
{
}
}
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Top-level probe type woven by the CodeReloadInPlace ILPostProcessor. Must be top-level (not
/// nested) so the generated override can reference it by name from a separate assembly.
/// </summary>
public class InPlaceReloadProbe
{
/// <summary>Value set by the original (unmodified) body of <see cref="Compute"/>.</summary>
public int baseline;
/// <summary>Value set by the reloaded override body of <see cref="Compute"/>.</summary>
public int hotValue;
/// <summary>Test target for the in-place code-reload weaving/dispatch mechanism.</summary>
[CodeReload]
public void Compute()
{
baseline = 1; // original body
}
}
/// <summary>
/// Probe with a PRIVATE field, reloaded through the Assembly.Load backend. Documents the backend
/// split: the interpreter reaches private members (reflection), but the Assembly.Load override is
/// real IL in a separate assembly, and Unity's Mono enforces field accessibility at JIT time — the
/// dispatch would throw FieldAccessException, so AccessibilityValidator rejects the reload up
/// front. A public getter exposes the private field for asserts.
/// </summary>
class InPlacePrivateReloadProbe
{
private int m_Secret;
public int Secret => m_Secret;
[CodeReload]
public void Compute()
{
m_Secret = 1; // original body
}
}
/// <summary>
/// Probe for the woven null-result guard: an override that returns null for a value-type
/// return has no value to unbox — the prologue must throw an actionable error, not the
/// opaque NullReferenceException a bare unbox.any produces.
/// </summary>
class NullResultWeaveTarget
{
public int ticks;
#pragma warning disable MS023 // value-returning on purpose: exercises the weaver's result prologue
[CodeReload]
public int Tick()
#pragma warning restore MS023
{
ticks++; // original body
return 7; // original result
}
}
/// <summary>
/// Probe for the nested-type gate: the weaver weaves <c>Inner.Boom</c> too (Cecil's
/// <c>GetTypes()</c> is recursive), but the reload pipeline only lifts methods declared
/// directly in the top-level class — a nested [CodeReload] must be skipped LOUDLY, never
/// silently dropped.
/// </summary>
public class NestedReloadProbe
{
public int ticks;
[CodeReload]
public void Tick()
{
ticks = 1; // original body
}
public class Inner
{
public int booms;
[CodeReload]
public void Boom()
{
booms = 1; // original body
}
}
}
/// <summary>
/// Probe mirroring the Pong sample's layout: the <c>[OnCodeReload]</c> callback is declared
/// BEFORE the <c>[CodeReload]</c> method. The callback is a post-reload hook, not a reload
/// target — the extractor must not push it (the player registry doesn't hold it, and its name
/// landing first in the pushed list used to fail the whole push).
/// </summary>
public class OnCodeReloadPushProbe
{
public int ticked;
public string reloadMarker = "none";
[OnCodeReload]
public void OnReloaded()
{
reloadMarker = "reloaded";
}
[CodeReload]
public void Tick()
{
ticked = 1;
}
}
/// <summary>
/// Probe whose compiled body mutates state; the empty-body reload test replaces it with a no-op.
/// </summary>
public class InPlaceEmptyBodyProbe
{
public int computedValue;
[CodeReload]
public void Compute()
{
computedValue = 42; // original body
}
}
/// <summary>
/// Tests for the in-place code 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.
/// </summary>
class CodeReloadInPlaceTests
{
// -------- weaving fixtures --------
public class WeaveTarget
{
public int ticks;
public bool overrideRan;
[CodeReload]
public void Tick()
{
ticks++; // original body
}
}
public static class WeaveOverrides
{
[CodeReloadOverrideMethod("WeaveTarget.Tick")]
public static void Tick(WeaveTarget instance)
{
instance.overrideRan = true;
}
}
// Tagged with FakeReload.CodeReloadAttribute — same simple name, wrong type. The weaver
// must not weave a dispatch prologue into Tick, so even a registered override never runs.
public class ForeignWeaveTarget
{
public int ticks;
public bool overrideRan;
[FakeReload.CodeReload]
public void Tick()
{
ticks++; // original body
}
}
public static class ForeignWeaveOverrides
{
[CodeReloadOverrideMethod("ForeignWeaveTarget.Tick")]
public static void Tick(ForeignWeaveTarget instance)
{
instance.overrideRan = true;
}
}
// Value-returning [CodeReload] method: exercises the weaver's result prologue
// (out-param dispatch + unbox.any) and the registry's TryInvokeCodeReloadResult path.
public class ValueWeaveTarget
{
public int ticks;
#pragma warning disable MS023 // value-returning on purpose: exercises the weaver's result prologue
[CodeReload]
public int Tick(int d)
#pragma warning restore MS023
{
ticks++; // original body
return d + 1; // original result
}
}
public static class ValueWeaveOverrides
{
[CodeReloadOverrideMethod("ValueWeaveTarget.Tick")]
public static int Tick(ValueWeaveTarget instance, int d)
{
return d + 100; // override result (distinct from the original body's d + 1)
}
}
// Override that raises IMGUI's control-flow exception mid-body. Dispatch must propagate
// it with its type intact (IMGUI catches ExitGUIException BY TYPE) instead of swallowing
// it and falling back to the original body.
public static class ExitGuiWeaveOverrides
{
[CodeReloadOverrideMethod("WeaveTarget.Tick")]
public static void Tick(WeaveTarget instance)
{
instance.overrideRan = true;
GUIUtility.ExitGUI();
}
}
private const string PublicSource = @"
using UnityEngine;
using Unity.Pipeline.CodeReload;
public class Demo : MonoBehaviour
{
public float speed = 1f;
[CodeReload]
void Tick()
{
var dt = Time.deltaTime;
transform.position += Vector3.right * speed * dt;
}
}";
// Exercises references that rely on the declaring type's scope and so must be re-qualified when
// the body is lifted into the standalone override class: an inherited static (FindObjectsByType,
// declared on UnityEngine.Object) and a nested type (the private enum Mode).
private const string ScopedSource = @"
using UnityEngine;
using Unity.Pipeline.CodeReload;
public class Scoped : MonoBehaviour
{
enum Mode { Idle, Run }
private Mode m_Mode = Mode.Idle;
[CodeReload]
void Tick()
{
var all = FindObjectsByType<Transform>(FindObjectsInactive.Exclude, FindObjectsSortMode.None);
m_Mode = all.Length > 0 ? Mode.Run : Mode.Idle;
}
}";
// Exercises a nested type in positions where the grammar requires a TypeSyntax (foreach
// declaration, local declaration, array type). The qualifier must produce a QualifiedName
// there — wrapping in a MemberAccessExpression makes the rewriter's typed rebuild throw
// InvalidCastException (as hit with TrackManager's nested MultiplierModifier delegate).
private const string TypePositionSource = @"
using UnityEngine;
using Unity.Pipeline.CodeReload;
public class Typed : MonoBehaviour
{
public delegate int Handler(int current);
public Handler OnChanged;
enum Mode { Idle, Run }
[CodeReload]
void Tick()
{
foreach (Handler h in OnChanged.GetInvocationList())
{
h(1);
}
Mode m = Mode.Idle;
var modes = new Mode[2];
modes[0] = m;
}
}";
private static Dictionary<string, string> Bodies(params string[] names)
{
var d = new Dictionary<string, string>();
foreach (var n in names) d[n] = "";
return d;
}
[SetUp]
public void Setup() => CodeReloadRegistry.ClearAllForTesting();
[TearDown]
public void TearDown() => CodeReloadRegistry.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()
{
CodeReloadRegistry.RegisterReloadableMethod(
typeof(WeaveTarget).GetMethod(nameof(WeaveTarget.Tick)),
new CodeReloadAttribute());
var registered = CodeReloadRegistry.RegisterMethodOverride(
typeof(WeaveOverrides).GetMethod(nameof(WeaveOverrides.Tick)),
new CodeReloadOverrideMethodAttribute("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");
}
[Test]
public void ValueReturning_NoOverride_RunsOriginalBody()
{
var t = new ValueWeaveTarget();
int r = t.Tick(5);
Assert.AreEqual(6, r, "Original body (d + 1) should run when no override is registered");
Assert.AreEqual(1, t.ticks);
}
[Test]
public void ValueReturning_WithOverride_ReturnsOverrideResult()
{
CodeReloadRegistry.RegisterReloadableMethod(
typeof(ValueWeaveTarget).GetMethod(nameof(ValueWeaveTarget.Tick)),
new CodeReloadAttribute());
var registered = CodeReloadRegistry.RegisterMethodOverride(
typeof(ValueWeaveOverrides).GetMethod(nameof(ValueWeaveOverrides.Tick)),
new CodeReloadOverrideMethodAttribute("ValueWeaveTarget.Tick"),
typeof(ValueWeaveOverrides),
out var reason);
Assert.IsTrue(registered, reason);
var t = new ValueWeaveTarget();
int r = t.Tick(5);
Assert.AreEqual(105, r, "Woven dispatch should return the override's result (d + 100)");
Assert.AreEqual(0, t.ticks, "Original body should be skipped when the override runs");
}
[Test] // A null override result cannot unbox to int — the woven guard must throw an
// actionable error instead of the opaque NullReferenceException of a bare unbox.
public void ValueReturning_NullOverrideResult_ThrowsActionable()
{
CodeReloadRegistry.RegisterReloadableMethod(
typeof(NullResultWeaveTarget).GetMethod(nameof(NullResultWeaveTarget.Tick)),
new CodeReloadAttribute());
var ok = CodeReloadRegistry.RegisterInterpreterMethodOverride(
"NullResultWeaveTarget.Tick", (instance, args) => null, out var reason);
Assert.IsTrue(ok, reason);
var t = new NullResultWeaveTarget();
var ex = Assert.Throws<System.InvalidOperationException>(() => t.Tick());
StringAssert.Contains("NullResultWeaveTarget.Tick", ex.Message);
StringAssert.Contains("returned null", ex.Message);
Assert.AreEqual(0, t.ticks, "Original body must not run after the override dispatched");
}
[Test]
public void WithThrowingOverride_PropagatesOriginalException()
{
CodeReloadRegistry.RegisterReloadableMethod(
typeof(WeaveTarget).GetMethod(nameof(WeaveTarget.Tick)),
new CodeReloadAttribute());
var registered = CodeReloadRegistry.RegisterMethodOverride(
typeof(ExitGuiWeaveOverrides).GetMethod(nameof(ExitGuiWeaveOverrides.Tick)),
new CodeReloadOverrideMethodAttribute("WeaveTarget.Tick"),
typeof(ExitGuiWeaveOverrides),
out var reason);
Assert.IsTrue(registered, reason);
var t = new WeaveTarget();
var ex = Assert.Throws<ExitGUIException>(() => t.Tick(),
"The override body's exception must propagate with its type intact, not be " +
"swallowed into a fallback (IMGUI catches ExitGUIException by type)");
Assert.IsTrue(t.overrideRan, "The override ran up to the throw");
Assert.AreEqual(0, t.ticks,
"The original body must NOT run as a fallback when the override body throws");
StringAssert.Contains(nameof(ExitGuiWeaveOverrides), ex.StackTrace,
"ExceptionDispatchInfo must preserve the original throw-site frames");
}
[Test]
public void Weaver_IgnoresForeignAttributeWithSameSimpleName()
{
// Even with a reloadable registration and a bound override in place, a method tagged
// with FakeReload.CodeReloadAttribute must run its original body: the weaver matches the
// real Unity.Pipeline.CodeReload.CodeReloadAttribute by full name and never weaves a
// dispatch prologue for the impostor.
CodeReloadRegistry.RegisterReloadableMethod(
typeof(ForeignWeaveTarget).GetMethod(nameof(ForeignWeaveTarget.Tick)),
new CodeReloadAttribute());
var registered = CodeReloadRegistry.RegisterMethodOverride(
typeof(ForeignWeaveOverrides).GetMethod(nameof(ForeignWeaveOverrides.Tick)),
new CodeReloadOverrideMethodAttribute("ForeignWeaveTarget.Tick"),
typeof(ForeignWeaveOverrides),
out var reason);
Assert.IsTrue(registered, reason);
var t = new ForeignWeaveTarget();
t.Tick();
Assert.IsFalse(t.overrideRan,
"No dispatch prologue may be woven for a foreign attribute named CodeReloadAttribute");
Assert.AreEqual(1, t.ticks, "Original body should run");
}
// -------- semantic transform + accessibility --------
[Test]
public void Transform_QualifiesInstanceMembers_LeavesLocalsAndStatics()
{
var output = SourceCodeTransformer.TransformMethodBodies(
Bodies("Tick"), "Demo", new Dictionary<string, MethodSignatureInfo>(), PublicSource);
StringAssert.Contains("[CodeReloadOverrideMethod(\"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);
}
private const string InterpolatedSource = @"
using UnityEngine;
public class Interp : MonoBehaviour
{
public int hp;
void Tick() { Debug.Log($""hp={hp}""); }
}";
[Test] // The interpreter can't run the compiler's interpolated-string lowering.
public void Transform_RewritesInterpolation_ForInterpreterBackend()
{
var output = SourceCodeTransformer.TransformMethodBodies(
Bodies("Tick"), "Interp", new Dictionary<string, MethodSignatureInfo>(),
InterpolatedSource);
StringAssert.Contains("string.Format(", output);
StringAssert.DoesNotContain("$\"", output);
}
[Test] // The Assembly.Load backend compiles interpolations natively — no rewrite.
public void Transform_KeepsInterpolation_ForCompiledBackend()
{
var output = SourceCodeTransformer.TransformMethodBodies(
Bodies("Tick"), "Interp", new Dictionary<string, MethodSignatureInfo>(),
InterpolatedSource, rewriteInterpolations: false);
StringAssert.Contains("$\"", output);
StringAssert.DoesNotContain("string.Format(", output);
}
// -------- nested-type gate --------
private const string NestedOnlySource = @"
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class NestedOnlyOuter
{
public class Inner
{
[CodeReload]
public void Boom() { }
}
}
}";
private const string MixedNestedSource = @"
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class NestedReloadProbe
{
public int ticks;
[CodeReload]
public void Tick()
{
ticks = 2; // edited body
}
public class Inner
{
public int booms;
[CodeReload]
public void Boom()
{
booms = 1; // original body
}
}
}
}";
[Test] // A file whose only [CodeReload] method is nested must fail with the real reason,
// not "No [CodeReload] methods found".
public void Reload_NestedOnlyCodeReload_FailsLoudly()
{
var path = Path.Combine(Application.temporaryCachePath, "NestedOnlyOuter_edit.cs");
try
{
File.WriteAllText(path, NestedOnlySource);
var result = InPlaceReloadProcessor.ProcessSourceFileOnMainThread(path);
Assert.IsFalse(result.Success);
StringAssert.Contains("'Boom'", result.ErrorMessage);
StringAssert.Contains("nested type 'NestedOnlyOuter.Inner'", result.ErrorMessage);
StringAssert.DoesNotContain("No [CodeReload] methods found", result.ErrorMessage);
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test] // The woven-but-unreloadable nested method must surface as a diagnostic while the
// top-level method still reloads.
public void Reload_NestedCodeReload_SkippedLoudly_OuterStillReloads()
{
CodeReloadRegistry.RegisterReloadableMethod(
typeof(NestedReloadProbe).GetMethod(nameof(NestedReloadProbe.Tick)),
new CodeReloadAttribute());
var path = Path.Combine(Application.temporaryCachePath, "NestedReloadProbe_edit.cs");
try
{
File.WriteAllText(path, MixedNestedSource);
var result = InPlaceReloadProcessor.ProcessSourceFileOnMainThread(path);
Assert.IsTrue(result.Success, result.ErrorMessage);
CollectionAssert.AreEquivalent(new[] { "NestedReloadProbe.Tick" }, result.RegisteredMethods);
Assert.IsTrue(result.CompilationDiagnostics.Any(d =>
d.Contains("'Boom'") && d.Contains("nested type 'NestedReloadProbe.Inner'")),
"Nested [CodeReload] must be skipped with an explicit diagnostic; got: " +
string.Join(" | ", result.CompilationDiagnostics));
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void Transform_QualifiesInheritedStaticsAndNestedTypes()
{
// The override class has no base type and isn't nested in Scoped, so bare `FindObjectsByType`
// (inherited static from UnityEngine.Object) and bare `Mode` (nested enum) would not bind.
// The transform must qualify them with their containing type so the override compiles without
// any change to the user's source.
var output = SourceCodeTransformer.TransformMethodBodies(
Bodies("Tick"), "Scoped", new Dictionary<string, MethodSignatureInfo>(), ScopedSource);
StringAssert.Contains("global::UnityEngine.Object.FindObjectsByType<Transform>", output);
StringAssert.Contains("global::Scoped.Mode.Run", output);
StringAssert.Contains("global::Scoped.Mode.Idle", output);
// The instance field is still qualified with instance, not treated as a static/type.
StringAssert.Contains("instance.m_Mode", output);
// The bare forms must be gone (no unqualified name left to fail resolution).
StringAssert.DoesNotContain("= FindObjectsByType<", output);
}
[Test]
public void Transform_QualifiesNestedTypesInTypePositions()
{
var output = SourceCodeTransformer.TransformMethodBodies(
Bodies("Tick"), "Typed", new Dictionary<string, MethodSignatureInfo>(), TypePositionSource);
// Type positions: foreach declaration, local declaration, array creation.
StringAssert.Contains("global::Typed.Handler h", output);
StringAssert.Contains("global::Typed.Mode m", output);
StringAssert.Contains("new global::Typed.Mode[2]", output);
// Expression position still qualifies via member access.
StringAssert.Contains("global::Typed.Mode.Idle", output);
StringAssert.Contains("instance.OnChanged", 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<string, MethodSignatureInfo>(), 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<string, MethodSignatureInfo>(), PublicSource);
StringAssert.DoesNotContain("#line", output);
}
// `var` binds to the inferred type. When that type is nested in the reloaded class the
// qualifier must leave the keyword alone instead of emitting `Outer.var` (CubeFlipr:
// `var inp = new BufferedInput { ... }` became `CubeMover.var inp`). Same for an alias.
private const string InferredNestedSource = @"
using UnityEngine;
using Unity.Pipeline.CodeReload;
using Alias = Inferred.Payload;
public class Inferred : MonoBehaviour
{
public struct Payload { public int dir; }
public Payload? pending;
static bool TryGet(out Payload p) { p = default; return true; }
[CodeReload]
void Tick()
{
var inp = new Payload { dir = 1 };
pending = inp;
var again = pending.Value;
TryGet(out var got);
Alias aliased = got;
foreach (var each in new Payload[1]) { }
}
}";
[Test]
public void Transform_LeavesVarAndAliasesAlone_WhenInferredTypeIsNested()
{
var output = SourceCodeTransformer.TransformMethodBodies(
Bodies("Tick"), "Inferred", new Dictionary<string, MethodSignatureInfo>(), InferredNestedSource);
StringAssert.DoesNotContain(".var", output);
StringAssert.DoesNotContain("Inferred.Alias", output);
StringAssert.Contains("var inp = new global::Inferred.Payload", output);
StringAssert.Contains("var again = instance.pending.Value", output);
StringAssert.Contains("TryGet(out var got)", output);
StringAssert.Contains("foreach (var each in new global::Inferred.Payload[1])", output);
}
[Test] // A compile error in the generated override must point at the user's file AND show
// the generated line — the rewritten source is what actually failed to compile.
public void ReloadFileInPlace_CompileError_ReportsUserLineAndGeneratedSource()
{
CodeReloadRegistry.RegisterReloadableMethod(
typeof(InPlaceReloadProbe).GetMethod(nameof(InPlaceReloadProbe.Compute)),
new CodeReloadAttribute());
var editedSource = @"
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class InPlaceReloadProbe
{
public int baseline;
public int hotValue;
[CodeReload]
public void Compute()
{
hotValue = NoSuchSymbol;
}
}
}";
var path = Path.Combine(Application.temporaryCachePath, "InPlaceReloadProbe_broken.cs");
File.WriteAllText(path, editedSource);
LogAssert.Expect(LogType.Error, new Regex("In-place reload compilation failed"));
try
{
var result = InPlaceReloadProcessor.ProcessSourceFileOnMainThread(path, useInterpreter: true);
Assert.IsFalse(result.Success);
var all = string.Join(" | ", result.CompilationDiagnostics);
var diag = result.CompilationDiagnostics.FirstOrDefault(d => d.Contains("CS0103"));
Assert.IsNotNull(diag, "expected CS0103, got: " + all);
// The statement sits on line 13 of the edited file, reached through the #line mapping.
StringAssert.Contains("InPlaceReloadProbe_broken.cs(13): error CS0103", diag);
StringAssert.Contains("generated line", diag);
StringAssert.Contains("instance.hotValue = NoSuchSymbol;", diag);
// The whole generated source is written out for inspection.
const string marker = "generated source: ";
StringAssert.Contains(marker, result.ErrorMessage);
var dump = result.ErrorMessage.Substring(result.ErrorMessage.IndexOf(marker) + marker.Length).TrimEnd(')');
Assert.IsTrue(File.Exists(dump), "dump not found: " + dump);
StringAssert.Contains("NoSuchSymbol", File.ReadAllText(dump));
File.Delete(dump);
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
// -------- 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.
CodeReloadRegistry.RegisterReloadableMethod(
typeof(InPlaceReloadProbe).GetMethod(nameof(InPlaceReloadProbe.Compute)),
new CodeReloadAttribute());
// The "edited" source: Compute now writes hotValue instead of baseline.
var editedSource = @"
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class InPlaceReloadProbe
{
public int baseline;
public int hotValue;
[CodeReload]
public void Compute()
{
hotValue = 99;
}
}
}";
var path = Path.Combine(Application.temporaryCachePath, "InPlaceReloadProbe_edit.cs");
File.WriteAllText(path, editedSource);
try
{
var response = CodeReloadCommands.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 [CodeReload] 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_EmptyBody_AppliesNoOpOverride()
{
CodeReloadRegistry.RegisterReloadableMethod(
typeof(InPlaceEmptyBodyProbe).GetMethod(nameof(InPlaceEmptyBodyProbe.Compute)),
new CodeReloadAttribute());
// Emptying a [CodeReload] body is a legitimate edit (e.g. deleting a Debug.Log): the
// reload must bind a no-op override, not fail with "No [CodeReload] methods found"
// (which would leave the previous override live).
var editedSource = @"
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class InPlaceEmptyBodyProbe
{
public int computedValue;
[CodeReload]
public void Compute()
{
}
}
}";
var path = Path.Combine(Application.temporaryCachePath, "InPlaceEmptyBodyProbe_edit.cs");
File.WriteAllText(path, editedSource);
try
{
var response = CodeReloadCommands.ReloadFile(path);
Assert.IsTrue(response.Success,
$"reload of an emptied [CodeReload] body should succeed. Error: {response.ErrorDetails}");
var probe = new InPlaceEmptyBodyProbe();
probe.Compute();
Assert.AreEqual(0, probe.computedValue,
"Empty override should replace the original body (which sets 42)");
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void ReloadFileInPlace_PrivateFieldAccess_FailsValidationUpFront()
{
// KNOWN LIMITATION of the Assembly.Load backend (the interpreter backend supports this —
// see IlInterpreterCodeReloadInterpreterTests). Roslyn's IgnoreAccessibility only disables
// COMPILE-time access checks; Unity's Mono (6.13) still enforces field accessibility when
// JIT-ing the loaded override IL, and does not honor [assembly: IgnoresAccessChecksTo]
// (verified empirically) — the failure would only surface on first dispatch as a
// FieldAccessException followed by a silent fallback to the original body. So
// AccessibilityValidator rejects the reload UP FRONT with an actionable error. If the
// runtime ever gains private access, drop the gate (enforcePublicAccess) and flip this
// test to assert Secret == 77.
CodeReloadRegistry.RegisterReloadableMethod(
typeof(InPlacePrivateReloadProbe).GetMethod(nameof(InPlacePrivateReloadProbe.Compute)),
new CodeReloadAttribute());
var editedSource = @"
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class InPlacePrivateReloadProbe
{
private int m_Secret;
public int Secret => m_Secret;
[CodeReload]
public void Compute()
{
m_Secret = 77;
}
}
}";
var path = Path.Combine(Application.temporaryCachePath, "InPlacePrivateReloadProbe_edit.cs");
File.WriteAllText(path, editedSource);
try
{
var response = CodeReloadCommands.ReloadFile(path);
Assert.IsFalse(response.Success,
"reload_file on the compiled backend must reject non-public access up front — " +
"Mono would JIT-enforce accessibility at dispatch and silently fall back.");
StringAssert.Contains("m_Secret", response.ErrorDetails,
"The validation error should name the offending member");
StringAssert.Contains("reload_file_editor_interpreter", response.ErrorDetails,
"The validation error should point at the interpreter backend as the fix");
var probe = new InPlacePrivateReloadProbe();
probe.Compute();
Assert.AreEqual(1, probe.Secret,
"The rejected reload must leave the original body (m_Secret = 1) in effect");
}
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.
CodeReloadRegistry.RegisterReloadableMethod(
typeof(InPlaceReloadProbe).GetMethod(nameof(InPlaceReloadProbe.Compute)),
new CodeReloadAttribute());
var editedSource = @"
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class InPlaceReloadProbe
{
public int baseline;
public int hotValue;
[CodeReload]
public void Compute()
{
hotValue = 99;
}
}
}";
var path = Path.Combine(Application.temporaryCachePath, "InPlaceReloadProbe_pdb_edit.cs");
File.WriteAllText(path, editedSource);
try
{
var response = CodeReloadCommands.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);
}
}
[Test]
public void CompileOverrideForPush_ExcludesOnCodeReloadCallback()
{
// [OnCodeReload] ends with "CodeReload", so a suffix-based attribute match extracts the
// callback as a reload target. Pushed first (declaration order), an unregistered name
// fails the player-side type resolution and the WHOLE file is skipped — the Pong sample
// failure. Only [CodeReload] methods may be pushed.
var editedSource = @"
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class OnCodeReloadPushProbe
{
public int ticked;
public string reloadMarker = ""none"";
[OnCodeReload]
public void OnReloaded()
{
reloadMarker = ""reloaded-edited"";
}
[CodeReload]
public void Tick()
{
ticked = 2;
}
}
}";
var path = Path.Combine(Application.temporaryCachePath, "OnCodeReloadPushProbe_edit.cs");
File.WriteAllText(path, editedSource);
try
{
var result = InPlaceReloadProcessor.CompileOverrideForPush(path);
Assert.IsTrue(result.Success, $"Push compile should succeed. Error: {result.Error}");
CollectionAssert.Contains(result.MethodNames, "Tick",
"[CodeReload] methods must be pushed.");
CollectionAssert.DoesNotContain(result.MethodNames, "OnReloaded",
"[OnCodeReload] is a post-reload callback, not a reload target — pushing it makes " +
"the player skip the entire file as unregistered.");
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
}
}