Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-09-21 14:21:07 +08:00
co-authored by Cursor
commit f3f66ab063
931 changed files with 112366 additions and 0 deletions
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3ffa6a16c12a1a248884139af2530848
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: de3774b0893944d8ac6871cbcaa631ab
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,211 @@
using System.Linq;
using NUnit.Framework;
using Newtonsoft.Json.Linq;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Editor.Commands.Animation;
using Unity.Pipeline.Models;
using UnityEditor;
using UnityEngine;
namespace Unity.Pipeline.Tests.Editor.Animation
{
/// <summary>
/// Tests for CLI-214 Group A (animation clips): create clip with frame rate + loop, set/get/remove
/// float curves, the overwrite-not-duplicate behaviour, the destructive remove guard, dry_run, and
/// the sandbox guard. Assets are generated in-test under a throwaway root and cleaned up.
/// </summary>
class AnimationClipCommandsTests
{
private const string Root = "Assets/__CLI214AnimTest";
[TearDown]
public void TearDown()
{
ProjectPaths.ResetAuthoringRoot();
if (AssetDatabase.IsValidFolder(Root))
{
AssetDatabase.DeleteAsset(Root);
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
}
}
private static ObjectRef PathRef(string path) => new ObjectRef { Path = path };
private static JArray TwoKeys() => new JArray
{
new JObject { ["time"] = 0f, ["value"] = 0f },
new JObject { ["time"] = 1f, ["value"] = 5f }
};
[Test]
public void CreateAnimationClip_SetsFrameRateAndLoop()
{
var path = Root + "/Clips/Walk.anim";
var result = AnimationClipCommands.CreateAnimationClip(path, frameRate: 30f, loop: true);
Assert.AreEqual(path, result.AssetPath);
var loaded = AssetDatabase.LoadAssetAtPath<AnimationClip>(path);
Assert.IsNotNull(loaded, "Clip should exist on disk");
Assert.AreEqual(30f, loaded.frameRate, 0.001f);
var info = AnimationClipCommands.GetAnimationClip(PathRef(path));
Assert.AreEqual(30f, info.FrameRate, 0.001f);
Assert.IsTrue(info.Loop, "loop should be reflected by get_animation_clip");
}
[Test]
public void CreateAnimationClip_OverwriteWithoutConfirm_Throws()
{
var path = Root + "/Clips/Dup.anim";
AnimationClipCommands.CreateAnimationClip(path);
Assert.Throws<System.ArgumentException>(() => AnimationClipCommands.CreateAnimationClip(path));
Assert.DoesNotThrow(() => AnimationClipCommands.CreateAnimationClip(path, confirm: true));
}
[Test]
public void CreateAnimationClip_OutOfProject_Throws()
{
Assert.Throws<System.ArgumentException>(() => AnimationClipCommands.CreateAnimationClip("../Outside.anim"));
Assert.Throws<System.ArgumentException>(() => AnimationClipCommands.CreateAnimationClip("Assets/../Outside.anim"));
}
[Test]
public void CreateAnimationClip_DryRun_WritesNothing()
{
var path = Root + "/Clips/DryRun.anim";
var result = AnimationClipCommands.CreateAnimationClip(path, dryRun: true);
Assert.AreEqual(path, result.AssetPath);
Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(path), "dry_run must not write a clip");
}
[Test]
public void SetAnimationCurve_AddsCurveWithKeys()
{
var path = Root + "/Clips/Curve.anim";
AnimationClipCommands.CreateAnimationClip(path);
var result = AnimationClipCommands.SetAnimationCurve(
PathRef(path), path: "", type: "Transform", property: "m_LocalPosition.x", keys: TwoKeys());
Assert.AreEqual(2, result.KeyCount);
Assert.AreEqual("m_LocalPosition.x", result.Binding.Property);
var info = AnimationClipCommands.GetAnimationClip(PathRef(path), includeKeys: true);
Assert.AreEqual(1, info.Bindings.Count);
var binding = info.Bindings[0];
Assert.AreEqual("Transform", binding.Type);
Assert.AreEqual("m_LocalPosition.x", binding.Property);
Assert.AreEqual(2, binding.KeyCount);
Assert.IsNotNull(binding.Keys);
Assert.AreEqual(2, binding.Keys.Count);
Assert.AreEqual(0f, binding.Keys[0].Value, 0.001f);
Assert.AreEqual(5f, binding.Keys[1].Value, 0.001f);
}
[Test]
public void SetAnimationCurve_ReplaceExisting_Overwrites()
{
var path = Root + "/Clips/Replace.anim";
AnimationClipCommands.CreateAnimationClip(path);
AnimationClipCommands.SetAnimationCurve(PathRef(path), "", "Transform", "m_LocalPosition.x", TwoKeys());
var threeKeys = new JArray
{
new JObject { ["time"] = 0f, ["value"] = 0f },
new JObject { ["time"] = 0.5f, ["value"] = 2f },
new JObject { ["time"] = 1f, ["value"] = 4f }
};
var result = AnimationClipCommands.SetAnimationCurve(PathRef(path), "", "Transform", "m_LocalPosition.x", threeKeys);
Assert.AreEqual(3, result.KeyCount);
var info = AnimationClipCommands.GetAnimationClip(PathRef(path));
// Same binding => not duplicated; the single binding now has 3 keys.
Assert.AreEqual(1, info.Bindings.Count, "Replacing a binding must not duplicate it");
Assert.AreEqual(3, info.Bindings[0].KeyCount);
}
[Test]
public void SetAnimationCurve_DryRun_WritesNothing()
{
var path = Root + "/Clips/DryCurve.anim";
AnimationClipCommands.CreateAnimationClip(path);
var result = AnimationClipCommands.SetAnimationCurve(
PathRef(path), "", "Transform", "m_LocalPosition.x", TwoKeys(), dryRun: true);
Assert.AreEqual(2, result.KeyCount);
var info = AnimationClipCommands.GetAnimationClip(PathRef(path));
Assert.AreEqual(0, info.Bindings.Count, "dry_run must not write a curve");
}
[Test]
public void RemoveAnimationCurve_WithoutConfirm_Throws()
{
var path = Root + "/Clips/Remove.anim";
AnimationClipCommands.CreateAnimationClip(path);
AnimationClipCommands.SetAnimationCurve(PathRef(path), "", "Transform", "m_LocalPosition.x", TwoKeys());
Assert.Throws<System.ArgumentException>(
() => AnimationClipCommands.RemoveAnimationCurve(PathRef(path), "", "Transform", "m_LocalPosition.x"),
"remove without confirm should be rejected");
var info = AnimationClipCommands.GetAnimationClip(PathRef(path));
Assert.AreEqual(1, info.Bindings.Count, "binding should survive a refused remove");
}
[Test]
public void RemoveAnimationCurve_WithConfirm_Removes()
{
var path = Root + "/Clips/Remove2.anim";
AnimationClipCommands.CreateAnimationClip(path);
AnimationClipCommands.SetAnimationCurve(PathRef(path), "", "Transform", "m_LocalPosition.x", TwoKeys());
AnimationClipCommands.RemoveAnimationCurve(PathRef(path), "", "Transform", "m_LocalPosition.x", confirm: true);
var info = AnimationClipCommands.GetAnimationClip(PathRef(path));
Assert.AreEqual(0, info.Bindings.Count, "binding should be gone after a confirmed remove");
}
[Test]
public void SetAnimationCurve_UnknownType_Throws()
{
var path = Root + "/Clips/BadType.anim";
AnimationClipCommands.CreateAnimationClip(path);
Assert.Throws<System.ArgumentException>(
() => AnimationClipCommands.SetAnimationCurve(PathRef(path), "", "NotARealComponent__CLI214", "m_X", TwoKeys()));
}
[Test]
public void SetAnimationCurve_ViaClient_Succeeds()
{
var path = Root + "/ViaClient/Curve.anim";
AnimationClipCommands.CreateAnimationClip(path);
using (var server = new PipelineTestServer())
{
var response = server.Execute("set_animation_curve", new
{
clip = new { path },
type = "Transform",
property = "m_LocalPosition.x",
keys = new object[]
{
new { time = 0f, value = 0f },
new { time = 1f, value = 5f }
}
});
Assert.IsTrue(response.IsSuccess, $"set_animation_curve should succeed: {response.Error}");
}
var info = AnimationClipCommands.GetAnimationClip(PathRef(path));
Assert.AreEqual(1, info.Bindings.Count);
Assert.AreEqual(2, info.Bindings[0].KeyCount);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 613a4bb343db4a4cb96ae6fef8195425
@@ -0,0 +1,261 @@
using NUnit.Framework;
using Newtonsoft.Json.Linq;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Editor.Commands.Animation;
using Unity.Pipeline.Models;
using UnityEditor;
using UnityEngine;
namespace Unity.Pipeline.Tests.Editor.Animation
{
/// <summary>
/// Tests for CLI-214 Group B (animator controllers): create controller (Base Layer, no states),
/// add parameter (and duplicate => duplicate_parameter), add layer, add state (with motion + as
/// default), add transition (with a matching condition), and the validation failures (missing
/// parameter, mode/type mismatch). Assets are generated in-test and cleaned up.
/// </summary>
class AnimatorControllerCommandsTests
{
private const string Root = "Assets/__CLI214CtrlTest";
[TearDown]
public void TearDown()
{
ProjectPaths.ResetAuthoringRoot();
if (AssetDatabase.IsValidFolder(Root))
{
AssetDatabase.DeleteAsset(Root);
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
}
}
private static ObjectRef PathRef(string path) => new ObjectRef { Path = path };
private string CreateController(string name)
{
var path = $"{Root}/{name}.controller";
AnimatorControllerCommands.CreateAnimatorController(path);
return path;
}
[Test]
public void CreateController_HasOneBaseLayerNoStates()
{
var path = CreateController("Empty");
var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path));
Assert.AreEqual(1, info.Layers.Count, "A new controller should have exactly one (Base) layer");
Assert.AreEqual(0, info.Layers[0].States.Count, "Base Layer should start with no states");
Assert.AreEqual(0, info.Parameters.Count);
}
[Test]
public void AddParameter_Float_AndDuplicate()
{
var path = CreateController("Params");
var added = AnimatorControllerCommands.AddAnimatorParameter(PathRef(path), "Speed", "Float", new JValue(0f));
Assert.IsInstanceOf<AddParameterResult>(added);
var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path));
Assert.AreEqual(1, info.Parameters.Count);
Assert.AreEqual("Speed", info.Parameters[0].Name);
Assert.AreEqual("Float", info.Parameters[0].ParamType);
// Duplicate name => structured error, not exception.
var dup = AnimatorControllerCommands.AddAnimatorParameter(PathRef(path), "Speed", "Float");
Assert.IsInstanceOf<ErrorResult>(dup);
Assert.AreEqual("duplicate_parameter", ((ErrorResult)dup).Code);
}
[Test]
public void AddLayer_AppendsLayer()
{
var path = CreateController("Layers");
var added = AnimatorControllerCommands.AddAnimatorLayer(PathRef(path), "Upper", weight: 0.5f, blendingMode: "Additive");
Assert.IsInstanceOf<AddLayerResult>(added);
Assert.AreEqual(1, ((AddLayerResult)added).Layer.Index);
var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path));
Assert.AreEqual(2, info.Layers.Count);
Assert.AreEqual("Upper", info.Layers[1].Name);
}
[Test]
public void AddState_WithMotionAndDefault()
{
var path = CreateController("States");
// A clip to use as the state motion.
var clipPath = Root + "/Idle.anim";
AnimationClipCommands.CreateAnimationClip(clipPath);
var added = AnimatorControllerCommands.AddAnimatorState(
PathRef(path), layer: null, name: "Idle", motion: PathRef(clipPath), isDefault: true);
Assert.IsInstanceOf<AddStateResult>(added);
var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path));
Assert.AreEqual(1, info.Layers[0].States.Count);
var state = info.Layers[0].States[0];
Assert.AreEqual("Idle", state.Name);
Assert.IsTrue(state.IsDefault, "Idle should be the layer default");
Assert.IsNotNull(state.Motion, "Idle should have a non-null motion");
Assert.AreEqual("Idle", info.Layers[0].DefaultState);
}
[Test]
public void AddState_UnknownLayerName_ReturnsLayerNotFound()
{
var path = CreateController("BadLayer");
var result = AnimatorControllerCommands.AddAnimatorState(
PathRef(path), layer: new JValue("NoSuchLayer"), name: "S");
Assert.IsInstanceOf<ErrorResult>(result);
Assert.AreEqual("layer_not_found", ((ErrorResult)result).Code);
}
[Test]
public void AddTransition_WithCondition()
{
var path = CreateController("Transitions");
AnimatorControllerCommands.AddAnimatorParameter(PathRef(path), "Speed", "Float", new JValue(0f));
AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Idle");
AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Run");
var conditions = new JArray
{
new JObject { ["parameter"] = "Speed", ["mode"] = "Greater", ["threshold"] = 0.1f }
};
var added = AnimatorControllerCommands.AddAnimatorTransition(
PathRef(path), null, "Idle", "Run", conditions);
Assert.IsInstanceOf<AddTransitionResult>(added);
Assert.AreEqual(1, ((AddTransitionResult)added).Transition.ConditionCount);
var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path));
var transitions = info.Layers[0].Transitions;
Assert.AreEqual(1, transitions.Count);
Assert.AreEqual("Idle", transitions[0].From);
Assert.AreEqual("Run", transitions[0].To);
Assert.AreEqual(1, transitions[0].Conditions.Count);
Assert.AreEqual("Speed", transitions[0].Conditions[0].Parameter);
Assert.AreEqual("Greater", transitions[0].Conditions[0].Mode);
}
[Test]
public void AddTransition_UnknownParameter_ThrowsAndWritesNothing()
{
var path = CreateController("BadParam");
AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Idle");
AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Run");
var conditions = new JArray
{
new JObject { ["parameter"] = "Ghost", ["mode"] = "Greater", ["threshold"] = 0.1f }
};
var ex = Assert.Throws<System.ArgumentException>(
() => AnimatorControllerCommands.AddAnimatorTransition(PathRef(path), null, "Idle", "Run", conditions));
StringAssert.Contains("Ghost", ex.Message, "error should name the offending parameter");
var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path));
Assert.AreEqual(0, info.Layers[0].Transitions.Count, "nothing should be written on a bad condition");
}
[Test]
public void AddTransition_ModeTypeMismatch_Throws()
{
var path = CreateController("Mismatch");
AnimatorControllerCommands.AddAnimatorParameter(PathRef(path), "Speed", "Float", new JValue(0f));
AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Idle");
AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Run");
// "If" is only valid for Bool/Trigger, not a Float parameter.
var conditions = new JArray
{
new JObject { ["parameter"] = "Speed", ["mode"] = "If" }
};
Assert.Throws<System.ArgumentException>(
() => AnimatorControllerCommands.AddAnimatorTransition(PathRef(path), null, "Idle", "Run", conditions));
}
[Test]
public void AddState_FractionalLayerIndex_ReturnsLayerNotFound()
{
var path = CreateController("FractionalLayer");
// 0.9 must NOT be silently rounded to layer 1; a non-integer index is rejected.
var result = AnimatorControllerCommands.AddAnimatorState(
PathRef(path), layer: new JValue(0.9d), name: "S");
Assert.IsInstanceOf<ErrorResult>(result);
Assert.AreEqual("layer_not_found", ((ErrorResult)result).Code);
}
[Test]
public void AddState_IntegerValuedFloatLayer_Resolves()
{
var path = CreateController("IntFloatLayer");
// 0.0 is an integer-valued float and resolves to the Base Layer (index 0).
var added = AnimatorControllerCommands.AddAnimatorState(
PathRef(path), layer: new JValue(0d), name: "Idle");
Assert.IsInstanceOf<AddStateResult>(added);
Assert.AreEqual(0, ((AddStateResult)added).State.Layer);
}
[Test]
public void AddTransition_ExitTimeOutOfRange_Throws()
{
var path = CreateController("BadExitTime");
AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Idle");
AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Run");
Assert.Throws<System.ArgumentException>(
() => AnimatorControllerCommands.AddAnimatorTransition(
PathRef(path), null, "Idle", "Run", conditions: null, hasExitTime: true, exitTime: 1.5f));
var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path));
Assert.AreEqual(0, info.Layers[0].Transitions.Count, "an out-of-range exitTime must write nothing");
}
[Test]
public void AddTransition_NegativeDuration_Throws()
{
var path = CreateController("BadDuration");
AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Idle");
AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Run");
Assert.Throws<System.ArgumentException>(
() => AnimatorControllerCommands.AddAnimatorTransition(
PathRef(path), null, "Idle", "Run", conditions: null, duration: -1f));
var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path));
Assert.AreEqual(0, info.Layers[0].Transitions.Count, "a negative duration must write nothing");
}
[Test]
public void AddParameter_DryRun_WritesNothing()
{
var path = CreateController("DryParam");
AnimatorControllerCommands.AddAnimatorParameter(PathRef(path), "Speed", "Float", new JValue(0f), dryRun: true);
var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path));
Assert.AreEqual(0, info.Parameters.Count, "dry_run must not write a parameter");
}
[Test]
public void CreateController_ViaClient_Succeeds()
{
using (var server = new PipelineTestServer())
{
var path = Root + "/ViaClient/Made.controller";
var response = server.Execute("create_animator_controller", new { path });
Assert.IsTrue(response.IsSuccess, $"create_animator_controller should succeed: {response.Error}");
Assert.IsNotNull(AssetDatabase.LoadMainAssetAtPath(path), "Controller should exist on disk");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c1d78b8f19fc48cea840bc62c3204b3e
@@ -0,0 +1,81 @@
using NUnit.Framework;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Editor.Commands.Animation;
using Unity.Pipeline.Models;
using UnityEditor;
namespace Unity.Pipeline.Tests.Editor.Animation
{
/// <summary>
/// Tests for CLI-214 Group C (Timeline). Timeline ships in the OPTIONAL com.unity.timeline package,
/// so this fixture must COMPILE and PASS whether or not the package is installed:
/// - <see cref="Timeline_NotInstalled_ReturnsPackageNotFound"/> runs only when the package is ABSENT
/// and asserts the structured package_not_found error (no exception).
/// - the create -> add track -> add clip -> get flow runs only when the package is PRESENT and
/// calls <c>Assert.Pass</c> (not <c>Assert.Ignore</c>) when absent, so Yamato strict-mode
/// agents see "Passed" rather than "Not Run" (Inconclusive) for the conditional skip.
///
/// All commands are reached through the public static methods; the Timeline types themselves are
/// only touched via reflection inside the commands, never referenced by this test assembly.
/// </summary>
class TimelineCommandsTests
{
private const string Root = "Assets/__CLI214TimelineTest";
[TearDown]
public void TearDown()
{
ProjectPaths.ResetAuthoringRoot();
if (AssetDatabase.IsValidFolder(Root))
{
AssetDatabase.DeleteAsset(Root);
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
}
}
private static ObjectRef PathRef(string path) => new ObjectRef { Path = path };
[Test]
public void Timeline_NotInstalled_ReturnsPackageNotFound()
{
if (TimelineGuard.IsInstalled())
Assert.Pass("com.unity.timeline is installed; the not-installed path is covered only when it is absent.");
var result = TimelineCommands.CreateTimeline(Root + "/T.playable");
Assert.IsInstanceOf<ErrorResult>(result, "Timeline commands must not throw when the package is absent");
Assert.AreEqual("package_not_found", ((ErrorResult)result).Code);
}
[Test]
public void Timeline_CreateTrackClipGet_Flow()
{
if (!TimelineGuard.IsInstalled())
Assert.Pass("com.unity.timeline is not installed; the Timeline authoring flow is validated only when the package is present.");
// create_timeline
var timelinePath = Root + "/Cutscene.playable";
var created = TimelineCommands.CreateTimeline(timelinePath, frameRate: 30f);
Assert.IsInstanceOf<AuthoringResult>(created, "create_timeline should return an AuthoringResult when installed");
// add an Animation track
var trackResult = TimelineCommands.AddTimelineTrack(PathRef(timelinePath), "Animation", name: "Anim");
Assert.IsInstanceOf<AddTimelineTrackResult>(trackResult);
// a clip asset for the Animation track
var clipPath = Root + "/Move.anim";
AnimationClipCommands.CreateAnimationClip(clipPath);
// add_timeline_clip (start 0, duration 2)
var clipResult = TimelineCommands.AddTimelineClip(
PathRef(timelinePath), track: "Anim", start: 0f, duration: 2f, asset: PathRef(clipPath));
Assert.IsInstanceOf<AddTimelineClipResult>(clipResult);
// get_timeline => one Animation track with one 2s clip
var info = (TimelineInfo)TimelineCommands.GetTimeline(PathRef(timelinePath));
Assert.AreEqual(1, info.Tracks.Count, "expected exactly one track");
Assert.AreEqual("Animation", info.Tracks[0].TrackType);
Assert.AreEqual(1, info.Tracks[0].Clips.Count, "expected exactly one clip");
Assert.AreEqual(2d, info.Tracks[0].Clips[0].Duration, 0.001d);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 72d8c657533f4b8b96feb3af780d619a
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: fddeee399ff1c104d82a62adbac36d43
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7571005014bc4727a0ef06b2056351a2
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,246 @@
using System.Text.RegularExpressions;
using NUnit.Framework;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Editor.Commands.Assets;
using Unity.Pipeline.Models;
using UnityEditor;
using UnityEngine;
using UnityEngine.TestTools;
namespace Unity.Pipeline.Tests.Editor.Assets
{
/// <summary>
/// A trivial ScriptableObject used by the asset-command tests to exercise create / find / move /
/// delete with a real, project-resolvable type.
/// </summary>
class CLI191SampleAsset : ScriptableObject
{
public int Value;
}
/// <summary>
/// Tests for the CLI-191 asset lifecycle commands, exercised both directly (static method) and via
/// the isolated <see cref="PipelineTestServer"/>. Covers the acceptance flow (create folder tree →
/// create ScriptableObject → find by type → move → delete), the sandbox guard (out-of-project
/// writes rejected), and the destructive guard (delete without confirm rejected).
/// </summary>
class AssetCommandsTests
{
private const string Root = "Assets/__CLI191Test";
[TearDown]
public void TearDown()
{
ProjectPaths.ResetAuthoringRoot();
if (AssetDatabase.IsValidFolder(Root))
{
AssetDatabase.DeleteAsset(Root);
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
}
}
private static ObjectRef PathRef(string path) => new ObjectRef { Path = path };
#region Direct
[Test]
public void CreateAsset_ScriptableObject_CreatesAssetAndReturnsIdentity()
{
var path = Root + "/Data/Sample.asset";
var result = AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName);
Assert.AreEqual(path, result.AssetPath);
Assert.IsNotEmpty(result.Guid, "Created asset should have a GUID");
Assert.IsNotNull(AssetDatabase.LoadAssetAtPath<CLI191SampleAsset>(path), "Asset should exist on disk");
}
[Test]
public void CreateAsset_OverwriteWithoutConfirm_Throws()
{
var path = Root + "/Data/Dup.asset";
AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName);
Assert.Throws<System.ArgumentException>(
() => AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName),
"Overwriting an existing asset without confirm should be rejected");
// With confirm it succeeds.
var result = AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName, confirm: true);
Assert.AreEqual(path, result.AssetPath);
}
[Test]
public void CreateAsset_OutOfProject_Throws()
{
Assert.Throws<System.ArgumentException>(
() => AssetCommands.CreateAsset("../Outside.asset", typeof(CLI191SampleAsset).FullName));
Assert.Throws<System.ArgumentException>(
() => AssetCommands.CreateAsset("Assets/../Outside.asset", typeof(CLI191SampleAsset).FullName));
}
[Test]
public void FindAssets_ByType_FindsCreatedAsset()
{
var path = Root + "/Find/Target.asset";
AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName);
var found = AssetCommands.FindAssets(type: nameof(CLI191SampleAsset), searchIn: Root);
Assert.GreaterOrEqual(found.Count, 1, "Should find the created asset by type");
CollectionAssert.Contains(
System.Linq.Enumerable.Select(found.Assets, a => a.AssetPath),
path);
}
[Test]
public void FindAssets_NoFilter_Throws()
{
Assert.Throws<System.ArgumentException>(() => AssetCommands.FindAssets());
}
[Test]
public void MoveAsset_RelocatesAndPreservesGuid()
{
var src = Root + "/Move/Src.asset";
AssetCommands.CreateAsset(src, typeof(CLI191SampleAsset).FullName);
var srcGuid = AssetDatabase.AssetPathToGUID(src);
var dest = Root + "/Move/Dest/Moved.asset";
var result = AssetCommands.MoveAsset(PathRef(src), dest);
Assert.AreEqual(dest, result.AssetPath);
Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(src), "Source should no longer exist");
Assert.AreEqual(srcGuid, AssetDatabase.AssetPathToGUID(dest), "GUID should be preserved across a move");
}
[Test]
public void CopyAsset_CreatesDistinctAsset()
{
var src = Root + "/Copy/Src.asset";
AssetCommands.CreateAsset(src, typeof(CLI191SampleAsset).FullName);
var dest = Root + "/Copy/Copy.asset";
var result = AssetCommands.CopyAsset(PathRef(src), dest);
Assert.AreEqual(dest, result.AssetPath);
Assert.IsNotNull(AssetDatabase.LoadMainAssetAtPath(src), "Source should still exist after copy");
Assert.AreNotEqual(AssetDatabase.AssetPathToGUID(src), AssetDatabase.AssetPathToGUID(dest),
"A copy should get a fresh GUID");
}
[Test]
public void RenameAsset_RenamesInPlace()
{
var src = Root + "/Rename/Old.asset";
AssetCommands.CreateAsset(src, typeof(CLI191SampleAsset).FullName);
var result = AssetCommands.RenameAsset(PathRef(src), "New");
Assert.AreEqual(Root + "/Rename/New.asset", result.AssetPath);
Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(src));
}
[Test]
public void DeleteAsset_WithoutConfirm_Throws()
{
var path = Root + "/Delete/Doomed.asset";
AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName);
Assert.Throws<System.ArgumentException>(() => AssetCommands.DeleteAsset(PathRef(path)),
"delete_asset without confirm should be rejected");
Assert.IsNotNull(AssetDatabase.LoadMainAssetAtPath(path), "Asset should survive a refused delete");
}
[Test]
public void DeleteAsset_WithConfirm_Deletes()
{
var path = Root + "/Delete/Doomed2.asset";
AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName);
var result = AssetCommands.DeleteAsset(PathRef(path), confirm: true);
Assert.AreEqual(path, result.AssetPath);
Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(path), "Asset should be gone after a confirmed delete");
}
[Test]
public void AcceptanceFlow_CreateFindMoveDelete()
{
// create folder tree
FolderCommands.CreateFolder(Root + "/Flow/Configs");
Assert.IsTrue(AssetDatabase.IsValidFolder(Root + "/Flow/Configs"));
// create ScriptableObject asset
var path = Root + "/Flow/Configs/Cfg.asset";
AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName);
// find by type
var found = AssetCommands.FindAssets(type: nameof(CLI191SampleAsset), searchIn: Root + "/Flow");
Assert.GreaterOrEqual(found.Count, 1);
// move it
var moved = AssetCommands.MoveAsset(PathRef(path), Root + "/Flow/Moved.asset");
Assert.AreEqual(Root + "/Flow/Moved.asset", moved.AssetPath);
// delete it (with confirm)
AssetCommands.DeleteAsset(PathRef(moved.AssetPath), confirm: true);
Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(moved.AssetPath));
}
#endregion
#region ViaClient
[Test]
public void CreateAsset_ViaClient_Succeeds()
{
using (var server = new PipelineTestServer())
{
var path = Root + "/ViaClient/Made.asset";
var response = server.Execute("create_asset", new { path, type = typeof(CLI191SampleAsset).FullName });
Assert.IsTrue(response.IsSuccess, $"create_asset should succeed: {response.Error}");
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field");
Assert.IsNotNull(AssetDatabase.LoadAssetAtPath<CLI191SampleAsset>(path), "Asset should exist");
}
}
[Test]
public void DeleteAsset_ViaClient_WithoutConfirm_Fails()
{
var path = Root + "/ViaClient/ToDelete.asset";
AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName);
using (var server = new PipelineTestServer())
{
// The server logs the refusal as a Unity [Error]; expect it so the test does not fail on
// the unexpected log. Matches the message thrown by DeleteAsset ("Refusing to delete ...").
LogAssert.Expect(LogType.Error, new Regex("Refusing to delete"));
var response = server.Execute("delete_asset", new { asset = new { path } });
Assert.IsFalse(response.IsSuccess, "delete_asset without confirm should fail over the wire");
Assert.IsNotNull(AssetDatabase.LoadMainAssetAtPath(path), "Asset should survive a refused delete");
}
}
[Test]
public void FindAssets_ViaClient_ReturnsResults()
{
var path = Root + "/ViaClient/Findable.asset";
AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName);
using (var server = new PipelineTestServer())
{
var response = server.Execute("find_assets", new { type = nameof(CLI191SampleAsset), search_in = Root });
Assert.IsTrue(response.IsSuccess, $"find_assets should succeed: {response.Error}");
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field");
}
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d4736a7747a847edb2355c95559ce0b0
@@ -0,0 +1,103 @@
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Editor.Commands.Assets;
using Unity.Pipeline.Models;
using UnityEditor;
namespace Unity.Pipeline.Tests.Editor.Assets
{
/// <summary>
/// Tests for set_import_settings (CLI-191). Uses the importer's <c>userData</c> property — present
/// on every AssetImporter — so the test is independent of any specific importer kind. Verifies the
/// applied/unknown bookkeeping and that the setting actually persists on the importer.
/// </summary>
class AssetImportCommandsTests
{
private const string Root = "Assets/__CLI191ImportTest";
[TearDown]
public void TearDown()
{
ProjectPaths.ResetAuthoringRoot();
if (AssetDatabase.IsValidFolder(Root))
{
AssetDatabase.DeleteAsset(Root);
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
}
}
private static ObjectRef PathRef(string path) => new ObjectRef { Path = path };
private static string CreateSampleAsset()
{
var path = Root + "/Imported.asset";
AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName);
return path;
}
#region Direct
[Test]
public void SetImportSettings_AppliesKnownMember()
{
var path = CreateSampleAsset();
var settings = new JObject { ["userData"] = "cli191" };
var result = AssetImportCommands.SetImportSettings(PathRef(path), settings);
CollectionAssert.Contains(result.Applied, "userData");
CollectionAssert.IsEmpty(result.Unknown);
Assert.AreEqual("cli191", AssetImporter.GetAtPath(path).userData, "Setting should persist on the importer");
}
[Test]
public void SetImportSettings_UnknownOnly_Throws()
{
var path = CreateSampleAsset();
var settings = new JObject { ["definitelyNotAMember"] = 42 };
Assert.Throws<System.ArgumentException>(
() => AssetImportCommands.SetImportSettings(PathRef(path), settings),
"When no supplied setting exists on the importer, the command should fail");
}
[Test]
public void SetImportSettings_DryRun_DoesNotPersist()
{
var path = CreateSampleAsset();
var settings = new JObject { ["userData"] = "dryrun" };
var result = AssetImportCommands.SetImportSettings(PathRef(path), settings, dryRun: true);
// dry_run reports the member as applicable but never mutates the importer.
CollectionAssert.Contains(result.Applied, "userData");
Assert.AreNotEqual("dryrun", AssetImporter.GetAtPath(path).userData, "dry_run should not persist the change");
}
#endregion
#region ViaClient
[Test]
public void SetImportSettings_ViaClient_Succeeds()
{
var path = CreateSampleAsset();
using (var server = new PipelineTestServer())
{
var response = server.Execute("set_import_settings", new
{
asset = new { path },
settings = new { userData = "viaclient" }
});
Assert.IsTrue(response.IsSuccess, $"set_import_settings should succeed: {response.Error}");
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
Assert.AreEqual("viaclient", AssetImporter.GetAtPath(path).userData);
}
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 121fa6671cf449ce863c389aa9b7e18c
@@ -0,0 +1,432 @@
using System.IO;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Editor.Commands.Assets;
using Unity.Pipeline.Models;
using UnityEditor;
using UnityEngine;
namespace Unity.Pipeline.Tests.Editor.Assets
{
/// <summary>
/// Tests for the CLI-212 per-type import settings + platform overrides:
/// <c>get_import_settings</c> and the platform-aware extension of <c>set_import_settings</c>.
///
/// Real importers are exercised by writing tiny generated assets, so the actual Unity read/write
/// code paths (including per-platform overrides) are covered end to end: a 4x4 PNG (TextureImporter),
/// a minimal OBJ (ModelImporter), and a tiny 8-bit PCM WAV (AudioImporter). The audio fixture drives
/// a full per-platform override round-trip (<c>ApplyAudioPlatform</c> +
/// <c>ReadAudioPlatformOverride</c>): set override, read it back, then clear it.
/// </summary>
class ImportSettingsCommandTests
{
private const string Root = "Assets/__CLI212ImportTest";
[TearDown]
public void TearDown()
{
ProjectPaths.ResetAuthoringRoot();
if (AssetDatabase.IsValidFolder(Root))
{
AssetDatabase.DeleteAsset(Root);
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
}
}
private static ObjectRef PathRef(string path) => new ObjectRef { Path = path };
// ------------------------------------------------------------------ asset fixtures
/// <summary>Write a tiny 4x4 PNG so Unity imports it with a TextureImporter.</summary>
private static string CreateTexture(string name = "Tex.png")
{
EnsureRoot();
var tex = new Texture2D(4, 4, TextureFormat.RGBA32, false);
var pixels = new Color32[16];
for (var i = 0; i < pixels.Length; i++)
pixels[i] = new Color32(255, 128, 64, 255);
tex.SetPixels32(pixels);
tex.Apply();
var bytes = tex.EncodeToPNG();
Object.DestroyImmediate(tex);
var projectPath = Root + "/" + name;
File.WriteAllBytes(ToAbsolute(projectPath), bytes);
AssetDatabase.ImportAsset(projectPath, ImportAssetOptions.ForceSynchronousImport);
return projectPath;
}
/// <summary>Write a minimal single-triangle OBJ so Unity imports it with a ModelImporter.</summary>
private static string CreateModel(string name = "Tri.obj")
{
EnsureRoot();
const string obj =
"o Tri\n" +
"v 0 0 0\n" +
"v 1 0 0\n" +
"v 0 1 0\n" +
"vn 0 0 1\n" +
"f 1//1 2//1 3//1\n";
var projectPath = Root + "/" + name;
File.WriteAllText(ToAbsolute(projectPath), obj);
AssetDatabase.ImportAsset(projectPath, ImportAssetOptions.ForceSynchronousImport);
return projectPath;
}
/// <summary>
/// Write a tiny mono 8-bit PCM WAV (a handful of samples) so Unity imports it with an
/// AudioImporter — enough to exercise the real per-platform override read/write paths.
/// </summary>
private static string CreateAudio(string name = "Beep.wav")
{
EnsureRoot();
const int sampleRate = 8000;
const int sampleCount = 64;
var pcm = new byte[sampleCount];
for (var i = 0; i < pcm.Length; i++)
pcm[i] = (byte)(128 + (i % 2 == 0 ? 32 : -32)); // crude square wave around the 8-bit midpoint
using (var stream = new MemoryStream())
using (var w = new BinaryWriter(stream))
{
const short channels = 1;
const short bitsPerSample = 8;
var byteRate = sampleRate * channels * bitsPerSample / 8;
var blockAlign = (short)(channels * bitsPerSample / 8);
// RIFF header
w.Write(new[] { 'R', 'I', 'F', 'F' });
w.Write(36 + pcm.Length);
w.Write(new[] { 'W', 'A', 'V', 'E' });
// fmt chunk
w.Write(new[] { 'f', 'm', 't', ' ' });
w.Write(16); // PCM fmt chunk size
w.Write((short)1); // audio format = PCM
w.Write(channels);
w.Write(sampleRate);
w.Write(byteRate);
w.Write(blockAlign);
w.Write(bitsPerSample);
// data chunk
w.Write(new[] { 'd', 'a', 't', 'a' });
w.Write(pcm.Length);
w.Write(pcm);
w.Flush();
var projectPath = Root + "/" + name;
File.WriteAllBytes(ToAbsolute(projectPath), stream.ToArray());
AssetDatabase.ImportAsset(projectPath, ImportAssetOptions.ForceSynchronousImport);
return projectPath;
}
}
private static void EnsureRoot()
{
if (!AssetDatabase.IsValidFolder(Root))
AssetDatabase.CreateFolder("Assets", "__CLI212ImportTest");
}
private static string ToAbsolute(string projectRelative) =>
$"{ProjectPaths.ProjectRoot.Replace('\\', '/')}/{projectRelative}";
// ------------------------------------------------------------------ texture: read
[Test]
public void GetImportSettings_Texture_ReturnsStructuredDefaultsAndOverrideBlock()
{
var path = CreateTexture();
var result = AssetImportCommands.GetImportSettings(PathRef(path), platform: "Android");
Assert.AreEqual("TextureImporter", result.ImporterType);
Assert.AreEqual("Android", result.Platform);
Assert.IsNotNull(result.Settings);
Assert.IsTrue(result.Settings.ContainsKey("textureType"));
Assert.IsTrue(result.Settings.ContainsKey("isReadable"));
Assert.IsTrue(result.Settings.ContainsKey("mipmapEnabled"));
Assert.IsTrue(result.Settings.ContainsKey("wrapMode"));
Assert.IsNotNull(result.PlatformOverride, "Texture should expose a platform override block");
Assert.IsTrue(result.PlatformOverride.ContainsKey("overridden"));
}
// ------------------------------------------------------------------ texture: default round-trip
[Test]
public void SetImportSettings_TextureDefault_AppliesAndReadBackReflects()
{
var path = CreateTexture();
var settings = new JObject
{
["isReadable"] = true,
["textureType"] = "NormalMap"
};
var set = AssetImportCommands.SetImportSettings(PathRef(path), settings);
CollectionAssert.Contains(set.Applied, "isReadable");
CollectionAssert.Contains(set.Applied, "textureType");
CollectionAssert.IsEmpty(set.Unknown);
var read = AssetImportCommands.GetImportSettings(PathRef(path));
Assert.AreEqual(true, read.Settings["isReadable"]);
Assert.AreEqual("NormalMap", read.Settings["textureType"]);
}
// ------------------------------------------------------------------ texture: platform override set + clear
[Test]
public void SetImportSettings_TextureAndroidOverride_SetsThenClears()
{
var path = CreateTexture();
// Default platform maxTextureSize before the override (should be unchanged afterwards).
var defaultBefore = AssetImportCommands.GetImportSettings(PathRef(path));
var defaultMaxBefore = defaultBefore.Settings["maxTextureSize"];
var set = AssetImportCommands.SetImportSettings(
PathRef(path),
new JObject { ["maxTextureSize"] = 1024, ["format"] = "ASTC_6x6", ["overridden"] = true },
platform: "Android");
CollectionAssert.Contains(set.Applied, "maxTextureSize");
CollectionAssert.Contains(set.Applied, "format");
var afterSet = AssetImportCommands.GetImportSettings(PathRef(path), platform: "Android");
Assert.AreEqual(true, afterSet.PlatformOverride["overridden"]);
Assert.AreEqual(1024, afterSet.PlatformOverride["maxTextureSize"]);
Assert.AreEqual("ASTC_6x6", afterSet.PlatformOverride["format"]);
// Default platform unchanged by the Android override.
var defaultAfter = AssetImportCommands.GetImportSettings(PathRef(path));
Assert.AreEqual(defaultMaxBefore, defaultAfter.Settings["maxTextureSize"],
"Default platform settings must be unchanged by a platform override");
// Clear the override.
AssetImportCommands.SetImportSettings(
PathRef(path),
new JObject { ["overridden"] = false },
platform: "Android");
var afterClear = AssetImportCommands.GetImportSettings(PathRef(path), platform: "Android");
Assert.AreEqual(false, afterClear.PlatformOverride["overridden"], "Override should be cleared");
}
// ------------------------------------------------------------------ audio: read
[Test]
public void GetImportSettings_Audio_ReturnsStructuredDefaultsAndOverrideBlock()
{
var path = CreateAudio();
var result = AssetImportCommands.GetImportSettings(PathRef(path), platform: "Android");
Assert.AreEqual("AudioImporter", result.ImporterType);
Assert.AreEqual("Android", result.Platform);
Assert.IsNotNull(result.Settings);
Assert.IsTrue(result.Settings.ContainsKey("loadType"));
Assert.IsTrue(result.Settings.ContainsKey("compressionFormat"));
Assert.IsTrue(result.Settings.ContainsKey("quality"));
Assert.IsNotNull(result.PlatformOverride, "Audio should expose a platform override block");
Assert.IsTrue(result.PlatformOverride.ContainsKey("overridden"));
// No override has been written yet.
Assert.AreEqual(false, result.PlatformOverride["overridden"]);
}
// ------------------------------------------------------------------ audio: platform override set + clear
[Test]
public void SetImportSettings_AudioAndroidOverride_SetsThenClears()
{
var path = CreateAudio();
// Default-platform compressionFormat before the override (must be unchanged afterwards).
var defaultBefore = AssetImportCommands.GetImportSettings(PathRef(path));
var defaultCompressionBefore = defaultBefore.Settings["compressionFormat"];
var set = AssetImportCommands.SetImportSettings(
PathRef(path),
new JObject { ["compressionFormat"] = "Vorbis", ["loadType"] = "CompressedInMemory", ["overridden"] = true },
platform: "Android");
CollectionAssert.Contains(set.Applied, "compressionFormat");
CollectionAssert.Contains(set.Applied, "loadType");
CollectionAssert.Contains(set.Applied, "overridden");
CollectionAssert.IsEmpty(set.Unknown);
var afterSet = AssetImportCommands.GetImportSettings(PathRef(path), platform: "Android");
Assert.AreEqual(true, afterSet.PlatformOverride["overridden"], "Override should be present after a platform write");
Assert.AreEqual("Vorbis", afterSet.PlatformOverride["compressionFormat"]);
Assert.AreEqual("CompressedInMemory", afterSet.PlatformOverride["loadType"]);
// Default platform unchanged by the Android override.
var defaultAfter = AssetImportCommands.GetImportSettings(PathRef(path));
Assert.AreEqual(defaultCompressionBefore, defaultAfter.Settings["compressionFormat"],
"Default platform settings must be unchanged by a platform override");
// Clear the override.
AssetImportCommands.SetImportSettings(
PathRef(path),
new JObject { ["overridden"] = false },
platform: "Android");
var afterClear = AssetImportCommands.GetImportSettings(PathRef(path), platform: "Android");
Assert.AreEqual(false, afterClear.PlatformOverride["overridden"], "Override should be cleared");
}
// ------------------------------------------------------------------ model: read + default write
[Test]
public void GetImportSettings_Model_ReturnsNullPlatformOverride()
{
var path = CreateModel();
var result = AssetImportCommands.GetImportSettings(PathRef(path));
Assert.AreEqual("ModelImporter", result.ImporterType);
Assert.IsTrue(result.Settings.ContainsKey("meshCompression"));
Assert.IsTrue(result.Settings.ContainsKey("animationType"));
Assert.IsTrue(result.Settings.ContainsKey("materialImportMode"));
Assert.IsNull(result.PlatformOverride, "ModelImporter has no per-platform overrides");
}
[Test]
public void SetImportSettings_ModelDefault_AppliesAndReadBack()
{
var path = CreateModel();
var set = AssetImportCommands.SetImportSettings(
PathRef(path),
new JObject { ["meshCompression"] = "High", ["importNormals"] = "Calculate" });
CollectionAssert.Contains(set.Applied, "meshCompression");
CollectionAssert.Contains(set.Applied, "importNormals");
var read = AssetImportCommands.GetImportSettings(PathRef(path));
Assert.AreEqual("High", read.Settings["meshCompression"]);
Assert.AreEqual("Calculate", read.Settings["importNormals"]);
}
[Test]
public void SetImportSettings_ModelWithPlatform_ReturnsNoPlatformOverrides_AndWritesNothing()
{
var path = CreateModel();
var before = AssetImportCommands.GetImportSettings(PathRef(path));
var meshBefore = before.Settings["meshCompression"];
var ex = Assert.Throws<System.ArgumentException>(() =>
AssetImportCommands.SetImportSettings(
PathRef(path),
new JObject { ["meshCompression"] = "High" },
platform: "iOS"));
StringAssert.Contains("no_platform_overrides", ex.Message);
var after = AssetImportCommands.GetImportSettings(PathRef(path));
Assert.AreEqual(meshBefore, after.Settings["meshCompression"], "A rejected platform write must change nothing");
}
// ------------------------------------------------------------------ dry_run
[Test]
public void SetImportSettings_DryRun_ReportsButDoesNotChange()
{
var path = CreateTexture();
var before = AssetImportCommands.GetImportSettings(PathRef(path));
var readableBefore = (bool)before.Settings["isReadable"];
var result = AssetImportCommands.SetImportSettings(
PathRef(path),
new JObject { ["isReadable"] = !readableBefore },
dryRun: true);
CollectionAssert.Contains(result.Applied, "isReadable");
var after = AssetImportCommands.GetImportSettings(PathRef(path));
Assert.AreEqual(readableBefore, (bool)after.Settings["isReadable"], "dry_run must not change settings");
}
// ------------------------------------------------------------------ unknown key handling
[Test]
public void SetImportSettings_UnknownKey_GoesToUnknown()
{
var path = CreateTexture();
var result = AssetImportCommands.SetImportSettings(
PathRef(path),
new JObject { ["isReadable"] = true, ["definitelyNotAMember"] = 42 });
CollectionAssert.Contains(result.Applied, "isReadable");
CollectionAssert.Contains(result.Unknown, "definitelyNotAMember");
}
[Test]
public void SetImportSettings_AllUnknown_Throws()
{
var path = CreateTexture();
Assert.Throws<System.ArgumentException>(() =>
AssetImportCommands.SetImportSettings(
PathRef(path),
new JObject { ["definitelyNotAMember"] = 42 }));
}
// ------------------------------------------------------------------ platform validation
[Test]
public void SetImportSettings_UnknownPlatform_Throws()
{
var path = CreateTexture();
var ex = Assert.Throws<System.ArgumentException>(() =>
AssetImportCommands.SetImportSettings(
PathRef(path),
new JObject { ["maxTextureSize"] = 512 },
platform: "PlayStation"));
StringAssert.Contains("unknown_platform", ex.Message);
}
[Test]
public void GetImportSettings_UnknownPlatform_Throws()
{
var path = CreateTexture();
Assert.Throws<System.ArgumentException>(() =>
AssetImportCommands.GetImportSettings(PathRef(path), platform: "PlayStation"));
}
// ------------------------------------------------------------------ sandbox guard
[Test]
public void GetImportSettings_OutsideAuthoringRoot_Throws()
{
var path = CreateTexture();
// Confine the authoring root to a sub-folder that does NOT contain the asset, so the asset's
// resolved path falls outside the sandbox.
ProjectPaths.AuthoringRoot = Root + "/Nested";
Assert.Throws<System.ArgumentException>(() =>
AssetImportCommands.GetImportSettings(PathRef(path)));
}
// ------------------------------------------------------------------ via client (wire) smoke test
[Test]
public void GetImportSettings_ViaClient_Succeeds()
{
var path = CreateTexture();
using (var server = new PipelineTestServer())
{
var response = server.Execute("get_import_settings", new
{
asset = new { path },
platform = "Android"
});
Assert.IsTrue(response.IsSuccess, $"get_import_settings should succeed: {response.Error}");
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 48fae49d74ce4b61941f9c5ff541034f
@@ -0,0 +1,182 @@
using System.Text.RegularExpressions;
using NUnit.Framework;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Editor.Commands.Assets;
using UnityEditor;
using UnityEngine;
using UnityEngine.TestTools;
namespace Unity.Pipeline.Tests.Editor.Assets
{
/// <summary>
/// Tests for create_asset's Material support (CLI-221), exercised directly and via the isolated
/// <see cref="PipelineTestServer"/>. Verifies that a .mat is created with a real (non-null) shader,
/// that an unknown shader name fails clearly (no null Material / null-ref), and that the created
/// Material is loadable and assignable to a Renderer's material slot.
/// </summary>
class MaterialAssetCommandsTests
{
private const string Root = "Assets/__CLI221_222Test";
// A shader that is guaranteed to exist on whatever pipeline this project runs (built-in or URP).
private string m_KnownShader;
[SetUp]
public void SetUp()
{
ProjectPaths.ResetAuthoringRoot();
m_KnownShader = PickKnownShader();
}
[TearDown]
public void TearDown()
{
ProjectPaths.ResetAuthoringRoot();
if (AssetDatabase.IsValidFolder(Root))
{
AssetDatabase.DeleteAsset(Root);
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
}
}
/// <summary>Pick a shader name that resolves on this project's active pipeline.</summary>
private static string PickKnownShader()
{
if (Shader.Find("Standard") != null)
return "Standard";
if (Shader.Find("Universal Render Pipeline/Lit") != null)
return "Universal Render Pipeline/Lit";
// Sprites/Default ships with every pipeline as a hard fallback.
return "Sprites/Default";
}
#region Direct
[Test]
public void CreateAsset_Material_WithShader_CreatesValidMaterial()
{
var path = Root + "/Mats/Bar.mat";
var result = AssetCommands.CreateAsset(path, "Material", shader: m_KnownShader);
Assert.AreEqual(path, result.AssetPath);
var loaded = AssetDatabase.LoadAssetAtPath<Material>(path);
Assert.IsNotNull(loaded, "Created .mat should load as a Material");
Assert.IsNotNull(loaded.shader, "Material's shader must not be null");
Assert.AreEqual(Shader.Find(m_KnownShader), loaded.shader, "Material should use the requested shader");
}
[Test]
public void CreateAsset_Material_DefaultShader_IsNonNull()
{
// No explicit shader: should fall back to the active pipeline's lit shader (or Standard).
var path = Root + "/Mats/Default.mat";
AssetCommands.CreateAsset(path, "Material");
var loaded = AssetDatabase.LoadAssetAtPath<Material>(path);
Assert.IsNotNull(loaded, "Created .mat should load as a Material");
Assert.IsNotNull(loaded.shader, "Default-shader Material must have a non-null shader");
}
[Test]
public void CreateAsset_Material_InvalidShader_ThrowsClearError()
{
var path = Root + "/Mats/Broken.mat";
var ex = Assert.Throws<System.ArgumentException>(
() => AssetCommands.CreateAsset(path, "Material", shader: "No/Such/Shader__CLI221"),
"An unknown shader name should fail with a clear ArgumentException");
StringAssert.Contains("not found", ex.Message);
Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(path), "No asset should be written on a shader failure");
}
[Test]
public void CreateAsset_Material_DryRun_InvalidShader_ThrowsAndWritesNothing()
{
// dry_run is documented as "validate inputs", so an unknown shader must fail fast even in
// dry_run (rather than reporting a would-be success) and still write nothing.
var path = Root + "/Mats/DryRunBroken.mat";
var ex = Assert.Throws<System.ArgumentException>(
() => AssetCommands.CreateAsset(path, "Material", shader: "No/Such/Shader__CLI221", dryRun: true),
"dry_run should resolve/validate the shader and fail on an unknown one");
StringAssert.Contains("not found", ex.Message);
Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(path), "dry_run must never write an asset");
}
[Test]
public void CreateAsset_Material_DryRun_ValidShader_WritesNothing()
{
// A valid dry_run resolves the shader (no throw) but writes no asset.
var path = Root + "/Mats/DryRunOk.mat";
var result = AssetCommands.CreateAsset(path, "Material", shader: m_KnownShader, dryRun: true);
Assert.AreEqual(path, result.AssetPath);
Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(path), "dry_run must never write an asset");
}
[Test]
public void CreateAsset_Material_AssignableToRendererSlot()
{
var path = Root + "/Mats/Assignable.mat";
AssetCommands.CreateAsset(path, "Material", shader: m_KnownShader);
var mat = AssetDatabase.LoadAssetAtPath<Material>(path);
var go = new GameObject("__CLI221_Renderer");
try
{
var renderer = go.AddComponent<MeshRenderer>();
renderer.sharedMaterial = mat;
Assert.AreEqual(mat, renderer.sharedMaterial, "Created Material should be assignable to a Renderer slot");
}
finally
{
Object.DestroyImmediate(go);
}
}
#endregion
#region ViaClient
[Test]
public void CreateAsset_Material_ViaClient_Succeeds()
{
using (var server = new PipelineTestServer())
{
var path = Root + "/ViaClient/Wall.mat";
var response = server.Execute("create_asset", new { path, type = "Material", shader = m_KnownShader });
Assert.IsTrue(response.IsSuccess, $"create_asset (Material) should succeed: {response.Error}");
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field");
var loaded = AssetDatabase.LoadAssetAtPath<Material>(path);
Assert.IsNotNull(loaded, "Material should exist on disk");
Assert.IsNotNull(loaded.shader, "Material's shader must not be null");
}
}
[Test]
public void CreateAsset_Material_ViaClient_InvalidShader_Fails()
{
using (var server = new PipelineTestServer())
{
// The server logs the failure as a Unity [Error]; expect it so the test does not fail on
// the unexpected log. Matches the message thrown by ResolveShader ("Shader '...' not found.").
LogAssert.Expect(LogType.Error, new Regex("not found"));
var path = Root + "/ViaClient/Broken.mat";
var response = server.Execute("create_asset", new { path, type = "Material", shader = "No/Such/Shader__CLI221" });
Assert.IsFalse(response.IsSuccess, "An unknown shader should fail over the wire");
Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(path), "No asset should be written on a shader failure");
}
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7d514449899cc0f439d287ab64433a22
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,158 @@
using NUnit.Framework;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Editor.Commands.Assets;
using UnityEditor;
using UnityEngine;
#if !UNITY_6000_0_OR_NEWER
using PhysicsMaterial = UnityEngine.PhysicMaterial;
#endif
namespace Unity.Pipeline.Tests.Editor.Assets
{
/// <summary>
/// Tests for create_asset's PhysicsMaterial support (CLI-222), exercised directly and via the
/// isolated <see cref="PipelineTestServer"/>. Verifies that BOTH the Unity 6 type name
/// "PhysicsMaterial" and the legacy "PhysicMaterial" resolve to <see cref="PhysicsMaterial"/>, that
/// the created asset carries the documented defaults (bounciness 0, frictions 0.6), is discoverable
/// via find_assets, and is assignable to a Collider's material slot.
///
/// NOTE: Unity 6 renamed the class to PhysicsMaterial but the on-disk asset extension is still
/// ".physicMaterial" (AssetDatabase.CreateAsset rejects ".physicsMaterial"). create_asset normalizes
/// a ".physicsMaterial" request to ".physicMaterial", so the created path is asserted via the
/// command result rather than the requested path.
/// </summary>
class PhysicsMaterialAssetCommandsTests
{
private const string Root = "Assets/__CLI221_222Test";
[SetUp]
public void SetUp()
{
ProjectPaths.ResetAuthoringRoot();
}
[TearDown]
public void TearDown()
{
ProjectPaths.ResetAuthoringRoot();
if (AssetDatabase.IsValidFolder(Root))
{
AssetDatabase.DeleteAsset(Root);
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
}
}
private static void AssertDefaults(PhysicsMaterial pm)
{
Assert.IsNotNull(pm, "Created physics material should load as a PhysicsMaterial");
Assert.AreEqual(0f, pm.bounciness, 1e-4f, "bounciness default should be 0");
Assert.AreEqual(0.6f, pm.dynamicFriction, 1e-4f, "dynamicFriction default should be 0.6");
Assert.AreEqual(0.6f, pm.staticFriction, 1e-4f, "staticFriction default should be 0.6");
}
#region Direct
[Test]
public void CreateAsset_PhysicsMaterial_CreatesWithDefaults()
{
var result = AssetCommands.CreateAsset(Root + "/Phys/Ball.physicMaterial", "PhysicsMaterial");
StringAssert.EndsWith(".physicMaterial", result.AssetPath);
AssertDefaults(AssetDatabase.LoadAssetAtPath<PhysicsMaterial>(result.AssetPath));
}
[Test]
public void CreateAsset_PhysicsMaterial_NormalizesModernExtension()
{
// CLI-222: an agent may pass the modern ".physicsMaterial" extension; it must succeed,
// normalized to the ".physicMaterial" file Unity actually writes.
var result = AssetCommands.CreateAsset(Root + "/Phys/Modern.physicsMaterial", "PhysicsMaterial");
StringAssert.EndsWith(".physicMaterial", result.AssetPath);
AssertDefaults(AssetDatabase.LoadAssetAtPath<PhysicsMaterial>(result.AssetPath));
}
[Test]
public void CreateAsset_PhysicsMaterial_LegacyTypeName_ResolvesToSameType()
{
// CLI-222: the legacy "PhysicMaterial" spelling must resolve to UnityEngine.PhysicsMaterial.
var result = AssetCommands.CreateAsset(Root + "/Phys/Legacy.physicMaterial", "PhysicMaterial");
var loaded = AssetDatabase.LoadMainAssetAtPath(result.AssetPath);
Assert.IsInstanceOf<PhysicsMaterial>(loaded, "Legacy 'PhysicMaterial' type name should resolve to PhysicsMaterial");
AssertDefaults(loaded as PhysicsMaterial);
}
[Test]
public void CreateAsset_PhysicsMaterial_FoundByFindAssets()
{
var result = AssetCommands.CreateAsset(Root + "/Phys/Findable.physicMaterial", "PhysicsMaterial");
var found = AssetCommands.FindAssets(type: "PhysicsMaterial", searchIn: Root);
Assert.GreaterOrEqual(found.Count, 1, "Should find the created PhysicsMaterial by type");
CollectionAssert.Contains(
System.Linq.Enumerable.Select(found.Assets, a => a.AssetPath),
result.AssetPath);
}
[Test]
public void CreateAsset_PhysicsMaterial_AssignableToColliderSlot()
{
var result = AssetCommands.CreateAsset(Root + "/Phys/Assignable.physicMaterial", "PhysicsMaterial");
var pm = AssetDatabase.LoadAssetAtPath<PhysicsMaterial>(result.AssetPath);
var go = new GameObject("__CLI222_Collider");
try
{
var collider = go.AddComponent<BoxCollider>();
collider.sharedMaterial = pm;
Assert.AreEqual(pm, collider.sharedMaterial, "Created PhysicsMaterial should be assignable to a Collider slot");
}
finally
{
Object.DestroyImmediate(go);
}
}
#endregion
#region ViaClient
[Test]
public void CreateAsset_PhysicsMaterial_ViaClient_Succeeds()
{
using (var server = new PipelineTestServer())
{
var response = server.Execute("create_asset",
new { path = Root + "/ViaClient/Ball.physicMaterial", type = "PhysicsMaterial" });
Assert.IsTrue(response.IsSuccess, $"create_asset (PhysicsMaterial) should succeed: {response.Error}");
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field");
var created = (string)response.JsonResponse["result"]["assetPath"];
AssertDefaults(AssetDatabase.LoadAssetAtPath<PhysicsMaterial>(created));
}
}
[Test]
public void CreateAsset_PhysicsMaterial_ViaClient_LegacyName_Succeeds()
{
using (var server = new PipelineTestServer())
{
var response = server.Execute("create_asset",
new { path = Root + "/ViaClient/Legacy.physicMaterial", type = "PhysicMaterial" });
Assert.IsTrue(response.IsSuccess, $"create_asset (legacy PhysicMaterial) should succeed: {response.Error}");
var created = (string)response.JsonResponse["result"]["assetPath"];
Assert.IsInstanceOf<PhysicsMaterial>(AssetDatabase.LoadMainAssetAtPath(created),
"Legacy 'PhysicMaterial' should resolve to PhysicsMaterial over the wire");
}
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: af05410e747b85c47ab38e0bf2db7fd8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,108 @@
using NUnit.Framework;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Editor.Commands.Assets;
using UnityEditor;
namespace Unity.Pipeline.Tests.Editor.Assets
{
/// <summary>
/// Tests for read_text_file / write_text_file (CLI-191), direct and via the isolated
/// <see cref="PipelineTestServer"/>. Covers round-tripping content, the overwrite-confirm guard, and
/// the sandbox guard (out-of-project writes rejected).
/// </summary>
class TextFileCommandsTests
{
private const string Root = "Assets/__CLI191TextTest";
[TearDown]
public void TearDown()
{
ProjectPaths.ResetAuthoringRoot();
if (AssetDatabase.IsValidFolder(Root))
{
AssetDatabase.DeleteAsset(Root);
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
}
}
#region Direct
[Test]
public void WriteThenRead_RoundTrips()
{
var path = Root + "/Notes/hello.txt";
const string content = "hello cli-191";
var write = TextFileCommands.WriteTextFile(path, content);
Assert.AreEqual(path, write.AssetPath);
var read = TextFileCommands.ReadTextFile(path);
Assert.AreEqual(content, read.Contents);
Assert.AreEqual(System.Text.Encoding.UTF8.GetByteCount(content), read.Bytes);
}
[Test]
public void WriteTextFile_OverwriteWithoutConfirm_Throws()
{
var path = Root + "/Notes/dup.txt";
TextFileCommands.WriteTextFile(path, "first");
Assert.Throws<System.ArgumentException>(
() => TextFileCommands.WriteTextFile(path, "second"),
"Overwriting an existing file without confirm should be rejected");
var ok = TextFileCommands.WriteTextFile(path, "second", confirm: true);
Assert.AreEqual(path, ok.AssetPath);
Assert.AreEqual("second", TextFileCommands.ReadTextFile(path).Contents);
}
[Test]
public void WriteTextFile_OutOfProject_Throws()
{
Assert.Throws<System.ArgumentException>(() => TextFileCommands.WriteTextFile("../escape.txt", "x"));
Assert.Throws<System.ArgumentException>(() => TextFileCommands.WriteTextFile("Assets/../escape.txt", "x"));
}
[Test]
public void ReadTextFile_Missing_Throws()
{
Assert.Throws<System.ArgumentException>(() => TextFileCommands.ReadTextFile(Root + "/does-not-exist.txt"));
}
#endregion
#region ViaClient
[Test]
public void WriteTextFile_ViaClient_Succeeds()
{
using (var server = new PipelineTestServer())
{
var path = Root + "/ViaClient/wire.txt";
var response = server.Execute("write_text_file", new { path, contents = "over the wire" });
Assert.IsTrue(response.IsSuccess, $"write_text_file should succeed: {response.Error}");
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
Assert.AreEqual("over the wire", TextFileCommands.ReadTextFile(path).Contents);
}
}
[Test]
public void ReadTextFile_ViaClient_Succeeds()
{
var path = Root + "/ViaClient/readable.txt";
TextFileCommands.WriteTextFile(path, "readable content");
using (var server = new PipelineTestServer())
{
var response = server.Execute("read_text_file", new { path });
Assert.IsTrue(response.IsSuccess, $"read_text_file should succeed: {response.Error}");
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field");
}
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4ede31badcf640f6acc3c5baf0fc8ef3
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 497149ab4929cd34483ff611ce25534a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,85 @@
using NUnit.Framework;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Editor.Commands.Assets;
using UnityEditor;
namespace Unity.Pipeline.Tests.Editor.Authoring
{
/// <summary>
/// Tests for the create_folder reference command (CLI-190), exercised directly and via
/// PipelineClient. Verifies the result envelope, recursive creation, Assets-relative paths,
/// and the traversal guard.
/// </summary>
class FolderCommandsTests
{
private const string Root = "Assets/__CLI190Test";
[SetUp]
public void SetUp()
{
// Start from the default authoring root so a leftover EditorPref (from another test or an
// interrupted run) can't shift where bare paths resolve mid-suite.
ProjectPaths.ResetAuthoringRoot();
}
[TearDown]
public void TearDown()
{
ProjectPaths.ResetAuthoringRoot();
if (AssetDatabase.IsValidFolder(Root))
{
AssetDatabase.DeleteAsset(Root);
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
}
}
#region Direct
[Test]
public void CreateFolder_ExplicitAssetsPath_CreatesFoldersAndReturnsIdentity()
{
var result = FolderCommands.CreateFolder(Root + "/Sub/Leaf");
Assert.IsTrue(AssetDatabase.IsValidFolder(Root + "/Sub/Leaf"), "Nested folders should exist");
Assert.AreEqual(Root + "/Sub/Leaf", result.AssetPath);
Assert.IsNotEmpty(result.Guid, "Created folder should have a GUID");
}
[Test]
public void CreateFolder_BarePath_ResolvesUnderAssets()
{
// No "Assets/" prefix — should resolve under the default authoring root (Assets/).
var result = FolderCommands.CreateFolder("__CLI190Test/Bare");
Assert.AreEqual("Assets/__CLI190Test/Bare", result.AssetPath);
Assert.IsTrue(AssetDatabase.IsValidFolder("Assets/__CLI190Test/Bare"));
}
[Test]
public void CreateFolder_Traversal_Throws()
{
Assert.Throws<System.ArgumentException>(() => FolderCommands.CreateFolder("../Outside"));
Assert.Throws<System.ArgumentException>(() => FolderCommands.CreateFolder("Assets/../Outside"));
}
#endregion
#region ViaClient
[Test]
public void CreateFolder_ViaClient_Succeeds()
{
using (var server = new PipelineTestServer())
{
var response = server.Execute("create_folder", new { path = Root + "/ViaClient" });
Assert.IsTrue(response.IsSuccess, $"create_folder should succeed: {response.Error}");
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field");
Assert.IsTrue(AssetDatabase.IsValidFolder(Root + "/ViaClient"), "Folder should be created");
}
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4653539f2cf4a1b4699bd2c7a8d35188
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,223 @@
using NUnit.Framework;
using Newtonsoft.Json;
using Unity.Pipeline;
using Unity.Pipeline.Models;
using UnityEngine;
using Object = UnityEngine.Object;
namespace Unity.Pipeline.Tests.Editor.Authoring
{
/// <summary>
/// Tests for <see cref="ObjectRefConverter"/>: a single canonical string handle coerces into the
/// matching ObjectRef field (mirroring ObjectResolver/ToString priority), structured objects still
/// deserialize fully, and serialization still emits a JSON object. Plus one end-to-end ViaClient
/// test proving a string target flows through the real /api/exec dispatch path.
/// </summary>
class ObjectRefConverterTests
{
private static ObjectRef Parse(string handle) =>
JsonConvert.DeserializeObject<ObjectRef>("\"" + handle + "\"");
#region String coercion (one field per canonical form)
[Test]
public void String_HierarchyPath_LeadingSlash()
{
var r = Parse("/Player");
Assert.AreEqual("/Player", r.HierarchyPath);
Assert.IsNull(r.GlobalId); Assert.IsNull(r.Path); Assert.IsNull(r.Guid);
Assert.IsNull(r.InstanceId); Assert.IsNull(r.FileId);
}
[Test]
public void String_HierarchyPath_Nested()
{
Assert.AreEqual("/Level/Floor", Parse("/Level/Floor").HierarchyPath);
}
[Test]
public void String_GlobalId()
{
var r = Parse("GlobalObjectId_V1-2-abc123def4560000abc123def4560000-1095335325-0");
Assert.AreEqual("GlobalObjectId_V1-2-abc123def4560000abc123def4560000-1095335325-0", r.GlobalId);
Assert.IsNull(r.HierarchyPath);
}
[Test]
public void String_AssetPath()
{
var r = Parse("Assets/Prefabs/Enemy.prefab");
Assert.AreEqual("Assets/Prefabs/Enemy.prefab", r.Path);
Assert.IsNull(r.HierarchyPath);
}
[Test]
public void String_GuidWithFileId()
{
var r = Parse("guid:a1b2c3d4e5f6:123");
Assert.AreEqual("a1b2c3d4e5f6", r.Guid);
Assert.AreEqual(123L, r.FileId);
}
[Test]
public void String_GuidOnly_Prefixed()
{
var r = Parse("guid:a1b2c3d4e5f6");
Assert.AreEqual("a1b2c3d4e5f6", r.Guid);
Assert.IsNull(r.FileId);
}
[Test]
public void String_InstanceId_Prefixed_Negative()
{
#if !UNITY_6000_4_OR_NEWER
Assert.AreEqual(ObjectId.FromRaw(-3070), Parse("instanceId:-3070").InstanceId);
#else
Assert.Ignore("Negative int instance ids do not exist in the EntityId (ulong) model on 6000.4+.");
#endif
}
[Test]
public void String_PlainNegativeInt_IsInstanceId()
{
#if !UNITY_6000_4_OR_NEWER
var r = Parse("-3070");
Assert.AreEqual(ObjectId.FromRaw(-3070), r.InstanceId);
Assert.IsNull(r.HierarchyPath);
#else
Assert.Ignore("Negative int instance ids do not exist in the EntityId (ulong) model on 6000.4+.");
#endif
}
[Test]
public void String_PlainPositiveInt_IsInstanceId()
{
Assert.AreEqual(ObjectId.FromRaw(48184), Parse("48184").InstanceId);
}
[Test]
public void String_32Hex_IsGuid()
{
var r = Parse("a1b2c3d4e5f60718293a4b5c6d7e8f90");
Assert.AreEqual("a1b2c3d4e5f60718293a4b5c6d7e8f90", r.Guid);
Assert.IsNull(r.InstanceId);
}
[Test]
public void String_BareName_FallsBackToHierarchyPath()
{
var r = Parse("Enemy_1");
Assert.AreEqual("Enemy_1", r.HierarchyPath);
Assert.IsNull(r.InstanceId); Assert.IsNull(r.Guid);
}
#endregion
#region Authoring-root-relative asset paths (AUTHAPI-9)
[Test]
public void String_RelativePathWithExtension_IsPath()
{
// No "Assets/" prefix but a file extension: an authoring-root-relative asset path.
var r = Parse("Materials/FloorA.mat");
Assert.AreEqual("Materials/FloorA.mat", r.Path);
Assert.IsNull(r.HierarchyPath);
}
[Test]
public void String_BareFileWithExtension_IsPath()
{
var r = Parse("Enemy.prefab");
Assert.AreEqual("Enemy.prefab", r.Path);
Assert.IsNull(r.HierarchyPath);
}
[Test]
public void String_RelativeNoExtension_StaysHierarchyPath()
{
// No extension and no leading slash: still a scene hierarchy path, not an asset candidate.
var r = Parse("Root/Child");
Assert.AreEqual("Root/Child", r.HierarchyPath);
Assert.IsNull(r.Path);
}
[Test]
public void String_LeadingSlashWithExtension_StaysHierarchyPath()
{
// A leading slash marks a canonical hierarchy path even when a name looks dotted.
var r = Parse("/Root/Cube.001");
Assert.AreEqual("/Root/Cube.001", r.HierarchyPath);
Assert.IsNull(r.Path);
}
#endregion
#region Object form, write, null
[Test]
public void Object_Structured_DeserializesAllFields()
{
var json = "{\"globalId\":\"G\",\"path\":\"Assets/A\",\"guid\":\"a1\",\"fileId\":42,\"instanceId\":7,\"hierarchyPath\":\"/H\"}";
var r = JsonConvert.DeserializeObject<ObjectRef>(json);
Assert.AreEqual("G", r.GlobalId);
Assert.AreEqual("Assets/A", r.Path);
Assert.AreEqual("a1", r.Guid);
Assert.AreEqual(42L, r.FileId);
Assert.AreEqual(ObjectId.FromRaw(7), r.InstanceId);
Assert.AreEqual("/H", r.HierarchyPath);
}
[Test]
public void WriteJson_EmitsObject_NotString_AndRoundTrips()
{
var json = JsonConvert.SerializeObject(new ObjectRef { HierarchyPath = "/Player" });
StringAssert.StartsWith("{", json, "ObjectRef must serialize as a JSON object, not a bare string");
StringAssert.Contains("\"hierarchyPath\":\"/Player\"", json);
var back = JsonConvert.DeserializeObject<ObjectRef>(json);
Assert.AreEqual("/Player", back.HierarchyPath);
}
[Test]
public void Null_DeserializesToNull()
{
Assert.IsNull(JsonConvert.DeserializeObject<ObjectRef>("null"));
}
[Test]
public void Number_BareInteger_IsInstanceId()
{
Assert.AreEqual(ObjectId.FromRaw(48184), JsonConvert.DeserializeObject<ObjectRef>("48184").InstanceId);
}
#endregion
#region End-to-end via the real dispatch path
[Test]
public void SetTransform_StringTarget_ResolvesAndApplies()
{
var go = new GameObject("CLI_ObjRefDispatch");
try
{
using (var server = new PipelineTestServer())
{
var resp = server.Execute("set_transform",
new { target = "/CLI_ObjRefDispatch", position = new[] { 1f, 2f, 3f } });
Assert.IsTrue(resp.IsSuccess,
$"set_transform with a string target should succeed (string->ObjectRef coercion): {resp.Error}");
}
Assert.AreEqual(new Vector3(1f, 2f, 3f), go.transform.localPosition,
"The string handle should have resolved to the GameObject and applied the transform");
}
finally
{
Object.DestroyImmediate(go);
}
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b218af7c5c4799a4ab37d7a670a6256e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,145 @@
using NUnit.Framework;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Models;
using UnityEditor;
using UnityEngine;
using Object = UnityEngine.Object;
using Unity.Pipeline;
namespace Unity.Pipeline.Tests.Editor.Authoring
{
/// <summary>
/// Tests for the object-reference resolver foundation (CLI-190): each handle form resolves back
/// to the same object, and Describe produces a canonical identity.
/// </summary>
class ObjectResolverTests
{
private const string AssetFolder = "Assets/AUTHAPI9_Res";
private GameObject m_SceneObject;
[TearDown]
public void TearDown()
{
if (m_SceneObject != null)
Object.DestroyImmediate(m_SceneObject);
m_SceneObject = null;
if (AssetDatabase.IsValidFolder(AssetFolder))
{
AssetDatabase.DeleteAsset(AssetFolder);
AssetDatabase.Refresh();
}
}
private static string CreateMaterialAsset(string name)
{
if (!AssetDatabase.IsValidFolder(AssetFolder))
AssetDatabase.CreateFolder("Assets", "AUTHAPI9_Res");
var shader = Shader.Find("Standard")
?? Shader.Find("Universal Render Pipeline/Lit")
?? Shader.Find("Sprites/Default");
var mat = new Material(shader);
var path = $"{AssetFolder}/{name}.mat";
AssetDatabase.CreateAsset(mat, path);
AssetDatabase.SaveAssets();
return path;
}
[Test]
public void Describe_SceneObject_ProducesInstanceIdAndHierarchyPath()
{
m_SceneObject = new GameObject("CLI190_Root");
var child = new GameObject("Child");
child.transform.SetParent(m_SceneObject.transform);
var info = ObjectResolver.Describe(child);
Assert.IsNotNull(info);
Assert.AreEqual(PipelineUtils.GetObjectId(child), info.InstanceId);
Assert.AreEqual("/CLI190_Root/Child", info.HierarchyPath);
Assert.AreEqual("GameObject", info.Type);
Assert.IsNull(info.AssetPath, "A scene object should not report an asset path");
}
[Test]
public void Resolve_ByInstanceId_ReturnsSameObject()
{
m_SceneObject = new GameObject("CLI190_ById");
var handle = new ObjectRef { InstanceId = PipelineUtils.GetObjectId(m_SceneObject) };
Assert.IsTrue(ObjectResolver.TryResolve(handle, out var obj, out var error), error);
Assert.AreSame(m_SceneObject, obj);
}
[Test]
public void Resolve_ByHierarchyPath_ReturnsSameObject()
{
m_SceneObject = new GameObject("CLI190_ByPath");
var handle = new ObjectRef { HierarchyPath = "/CLI190_ByPath" };
Assert.IsTrue(ObjectResolver.TryResolve(handle, out var obj, out var error), error);
Assert.AreSame(m_SceneObject, obj);
}
[Test]
public void Resolve_EmptyHandle_FailsWithError()
{
Assert.IsFalse(ObjectResolver.TryResolve(new ObjectRef(), out _, out var error));
Assert.IsNotEmpty(error);
}
[Test]
public void Resolve_UnknownGuid_FailsWithError()
{
var handle = new ObjectRef { Guid = "00000000000000000000000000000000" };
Assert.IsFalse(ObjectResolver.TryResolve(handle, out _, out var error));
Assert.IsNotEmpty(error);
}
[Test]
public void Resolve_ByRelativeAssetPath_NormalizesUnderAuthoringRoot()
{
// A path without the "Assets/" prefix (AUTHAPI-9) resolves under the authoring root.
var full = CreateMaterialAsset("Floor");
var expected = AssetDatabase.LoadMainAssetAtPath(full);
var handle = new ObjectRef { Path = "AUTHAPI9_Res/Floor.mat" };
Assert.IsTrue(ObjectResolver.TryResolve(handle, out var obj, out var error), error);
Assert.AreSame(expected, obj);
}
[Test]
public void Resolve_ExplicitAssetsPath_StillResolves()
{
var full = CreateMaterialAsset("Wall");
var handle = new ObjectRef { Path = full };
Assert.IsTrue(ObjectResolver.TryResolve(handle, out var obj, out var error), error);
Assert.AreSame(AssetDatabase.LoadMainAssetAtPath(full), obj);
}
[Test]
public void Resolve_DottedGameObjectName_FallsBackToHierarchy()
{
// A dotted name (e.g. "Cube.001") is routed to Path by the string converter but is really a
// scene object; the Path branch falls back to a hierarchy lookup.
m_SceneObject = new GameObject("Cube.001");
var handle = new ObjectRef { Path = "Cube.001" };
Assert.IsTrue(ObjectResolver.TryResolve(handle, out var obj, out var error), error);
Assert.AreSame(m_SceneObject, obj);
}
[Test]
public void Resolve_UnresolvableRelativePath_ErrorListsEveryStrategy()
{
var handle = new ObjectRef { Path = "AUTHAPI9_Res/Missing.mat" };
Assert.IsFalse(ObjectResolver.TryResolve(handle, out _, out var error));
StringAssert.Contains("no asset at", error);
StringAssert.Contains("no GameObject at hierarchy path", error);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e864b00059a57364d902089021b33742
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,84 @@
using NUnit.Framework;
using Unity.Pipeline.Editor.Authoring;
namespace Unity.Pipeline.Tests.Editor.Authoring
{
/// <summary>
/// Tests for ProjectPaths (CLI-190): Assets-relative resolution, the traversal/out-of-root
/// guards, and the configurable authoring root.
/// </summary>
class ProjectPathsTests
{
[SetUp]
public void SetUp()
{
// Reset to the default root before each test so a leftover EditorPref can't leak in.
ProjectPaths.ResetAuthoringRoot();
}
[TearDown]
public void TearDown()
{
ProjectPaths.ResetAuthoringRoot();
}
[Test]
public void DefaultRoot_IsAssets()
{
ProjectPaths.ResetAuthoringRoot();
Assert.AreEqual("Assets", ProjectPaths.AuthoringRoot);
}
[Test]
public void Resolve_BarePath_PrependsAssets()
{
var resolved = ProjectPaths.Resolve("Gameplay/Enemies", out var error);
Assert.IsNull(error, error);
Assert.AreEqual("Assets/Gameplay/Enemies", resolved);
}
[Test]
public void Resolve_ExplicitAssetsPath_Unchanged()
{
var resolved = ProjectPaths.Resolve("Assets/Gameplay", out var error);
Assert.IsNull(error, error);
Assert.AreEqual("Assets/Gameplay", resolved);
}
[Test]
public void Resolve_Traversal_Fails()
{
Assert.IsNull(ProjectPaths.Resolve("../Outside", out var error));
Assert.IsNotEmpty(error);
}
[Test]
public void Resolve_EmptyPath_Fails()
{
Assert.IsNull(ProjectPaths.Resolve("", out var error));
Assert.IsNotEmpty(error);
}
[Test]
public void ConfigurableRoot_ConfinesBareAndRejectsOutside()
{
ProjectPaths.AuthoringRoot = "Assets/__CLI190Root";
// Bare path resolves under the configured root.
var inside = ProjectPaths.Resolve("Foo/Bar", out var insideError);
Assert.IsNull(insideError, insideError);
Assert.AreEqual("Assets/__CLI190Root/Foo/Bar", inside);
// Explicit path outside the configured root is rejected (confinement).
Assert.IsNull(ProjectPaths.Resolve("Assets/Other", out var outsideError));
Assert.IsNotEmpty(outsideError);
}
[Test]
public void SetAuthoringRoot_OutsideAssets_Throws()
{
Assert.Throws<System.ArgumentException>(() => ProjectPaths.AuthoringRoot = "Outside");
Assert.Throws<System.ArgumentException>(() => ProjectPaths.AuthoringRoot = "Assets/../Escape");
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9af4698d059fd9d43b72c6e1dad56424
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,114 @@
using NUnit.Framework;
using Unity.Pipeline.Editor.Commands;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Tests for auto-tick's SessionState persistence across (simulated) domain reloads.
/// The live editor's own server may already have auto-tick enabled, so SetUp/TearDown save and
/// restore whatever state was live before each test instead of assuming it starts off.
/// </summary>
class AutoTickCommandTests
{
private bool m_PrevEnabled;
private long m_PrevIntervalMs;
private bool m_HadPersisted;
private bool m_PrevPersistedEnabled;
private long m_PrevPersistedIntervalMs;
[SetUp]
public void SetUp()
{
m_PrevEnabled = AutoTickCommand.IsEnabled;
m_PrevIntervalMs = AutoTickCommand.CurrentIntervalMs;
m_HadPersisted = AutoTickCommand.TryGetPersistedSessionForTests(out m_PrevPersistedEnabled, out m_PrevPersistedIntervalMs);
AutoTickCommand.EraseSessionForTests();
}
[TearDown]
public void TearDown()
{
// persist:false so this only restores the live in-memory state, not SessionState -
// the persisted baseline (or lack thereof) is restored/erased separately below.
AutoTickCommand.SetAutoTick(m_PrevEnabled, (int)m_PrevIntervalMs, persist: false);
if (m_HadPersisted)
AutoTickCommand.SetPersistedSessionForTests(m_PrevPersistedEnabled, m_PrevPersistedIntervalMs);
else
AutoTickCommand.EraseSessionForTests();
}
[TestCase(true, 5)]
[TestCase(true, 250)]
[TestCase(false, 40)]
public void RestoreFromSession_AfterSimulatedReload_RestoresExactPriorState(bool enable, int intervalMs)
{
AutoTickCommand.SetAutoTick(enable, intervalMs);
// A domain reload clears the statics (and the update-loop subscription with them) but
// leaves the SessionState copy this call just wrote intact.
AutoTickCommand.ResetForTests();
// Pass the opposite of the persisted value as the default, so a passing assertion proves
// the persisted state won, not the default falling through by coincidence.
AutoTickCommand.RestoreFromSession(defaultEnabled: !enable);
Assert.AreEqual(enable, AutoTickCommand.IsEnabled,
"Restore must bring back the exact enabled state set via SetAutoTick, not the startup default.");
if (enable)
{
Assert.AreEqual(intervalMs, AutoTickCommand.CurrentIntervalMs,
"Restore must bring back the exact interval set via SetAutoTick, not the default interval.");
}
}
[TestCase(true)]
[TestCase(false)]
public void RestoreFromSession_NoPriorExplicitCallThisSession_AppliesDefault(bool defaultEnabled)
{
// SetUp already erased SessionState and this simulates a reload on top of that, so there
// is no persisted choice for RestoreFromSession to find.
AutoTickCommand.ResetForTests();
AutoTickCommand.RestoreFromSession(defaultEnabled);
Assert.AreEqual(defaultEnabled, AutoTickCommand.IsEnabled);
if (defaultEnabled)
{
Assert.AreEqual(AutoTickCommand.DefaultIntervalMs, AutoTickCommand.CurrentIntervalMs,
"With no explicit prior choice, restoring 'enabled' must use the default interval.");
}
}
[Test]
public void SetAutoTick_WithPersistFalse_RevertsToPriorPersistedBaselineAfterSimulatedReload()
{
// Establish a persisted baseline, as if the user had configured a normal auto-tick rate.
AutoTickCommand.SetAutoTick(true, 200);
// A temporary, expensive override (interval_ms=0 pegs a CPU core) that must not overwrite
// the baseline above.
AutoTickCommand.SetAutoTick(true, 0, persist: false);
Assert.AreEqual(0, AutoTickCommand.CurrentIntervalMs, "The temporary override should still apply immediately.");
AutoTickCommand.ResetForTests();
AutoTickCommand.RestoreFromSession(defaultEnabled: false);
Assert.AreEqual(200, AutoTickCommand.CurrentIntervalMs,
"persist:false must not clobber the previously persisted baseline.");
}
[Test]
public void SetAutoTick_WithPersistFalse_NoPriorBaseline_DoesNotSurviveSimulatedReload()
{
AutoTickCommand.SetAutoTick(true, 0, persist: false);
Assert.IsTrue(AutoTickCommand.IsEnabled);
AutoTickCommand.ResetForTests();
AutoTickCommand.RestoreFromSession(defaultEnabled: false);
Assert.IsFalse(AutoTickCommand.IsEnabled,
"With no persisted baseline, a session-only (persist:false) call must not survive a reload.");
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 33094abdda6430b4d883852f20d4132f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5e5fd89f0ad042538cfef1bd87763a27
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,199 @@
using System.Linq;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor;
using Unity.Pipeline.Editor.Commands.Baking;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine.SceneManagement;
namespace Unity.Pipeline.Tests.Editor.Baking
{
/// <summary>
/// Tests for the CLI-215 lighting bake commands. These cover the parts that are deterministic
/// without waiting on a real (minutes-long) lightmap bake: command discovery/schema, the
/// settings get/set round-trip, the destructive clear guard, the idle status, the no_scene guard,
/// and dry_run being a no-op. The full bake → completed acceptance flow is exercised live against a
/// running Editor (it is too slow/platform-dependent for the unit suite).
/// </summary>
class LightingBakeCommandsTests
{
[SetUp]
public void SetUp() => CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
[TearDown]
public void TearDown()
{
// Leave the editor with a fresh empty (untitled) scene so other tests are unaffected.
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
}
[Test]
public void LightingCommands_AreDiscovered_WithExpectedSchema()
{
var commands = CommandRegistry.DiscoverCommands().ToList();
var bake = commands.FirstOrDefault(c => c.Name == "bake_lighting");
Assert.IsNotNull(bake, "Should discover bake_lighting");
CollectionAssert.AreEquivalent(new[] { "confirm", "dry_run" },
bake.Parameters.Select(p => p.Name).ToList());
var status = commands.FirstOrDefault(c => c.Name == "lighting_bake_status");
Assert.IsNotNull(status, "Should discover lighting_bake_status");
Assert.IsFalse(status.MainThreadRequired,
"lighting_bake_status must be off-main-thread so it answers while a bake holds the main thread");
Assert.AreEqual(0, status.Parameters.Count, "lighting_bake_status takes no parameters");
Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "cancel_lighting_bake"));
Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "clear_baked_lighting"));
Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "get_lighting_settings"));
Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "set_lighting_settings"));
}
[Test]
public void GetLightingSettings_ReturnsCoreFields()
{
var settings = LightingBakeCommands.GetLightingSettings();
if (!settings.Available)
{
// No LightingSettings are exposed when no scene is open (e.g. batchmode test
// projects start with an untitled empty scene). Skip rather than fail.
Assert.Pass("No active LightingSettings — skipping (no scene open in test project).");
return;
}
Assert.IsNotNull(settings.Lightmapper, "lightmapper should be reported");
// directionalMode must be one of the two spec values, never a raw flags-enum string.
Assert.That(settings.DirectionalMode, Is.EqualTo("NonDirectional").Or.EqualTo("Directional"),
"directionalMode must be 'NonDirectional' or 'Directional'");
// lightmapCompression must be the LightmapCompression enum name, not a bool.
var validCompressions = new[] { "None", "LowQuality", "NormalQuality", "HighQuality" };
CollectionAssert.Contains(validCompressions, settings.LightmapCompression,
"lightmapCompression must be one of the LightmapCompression enum names");
}
[Test]
public void SetLightingSettings_DirectionalMode_RoundTrips()
{
var before = LightingBakeCommands.GetLightingSettings();
if (!before.Available)
{
Assert.Pass("No active LightingSettings — skipping directionalMode round-trip test.");
return;
}
var target = before.DirectionalMode == "NonDirectional" ? "Directional" : "NonDirectional";
try
{
var result = JObject.FromObject(LightingBakeCommands.SetLightingSettings(
new JObject { ["directionalMode"] = target }));
CollectionAssert.Contains(result["applied"].Select(t => t.Value<string>()).ToList(),
"directionalMode", "directionalMode should be in applied[]");
var after = LightingBakeCommands.GetLightingSettings();
Assert.That(after.DirectionalMode, Is.EqualTo("NonDirectional").Or.EqualTo("Directional"),
"directionalMode must remain a spec-valid string after set");
}
finally
{
LightingBakeCommands.SetLightingSettings(new JObject { ["directionalMode"] = before.DirectionalMode });
}
}
[Test]
public void SetLightingSettings_RoundTrips_BouncesAndResolution()
{
var before = LightingBakeCommands.GetLightingSettings();
var newBounces = before.Bounces == 3 ? 2 : 3;
var newResolution = before.LightmapResolution >= 40f ? 20f : 50f;
try
{
var settings = new JObject
{
["bounces"] = newBounces,
["lightmapResolution"] = newResolution
};
LightingBakeCommands.SetLightingSettings(settings);
var after = LightingBakeCommands.GetLightingSettings();
Assert.AreEqual(newBounces, after.Bounces, "bounces should round-trip");
Assert.AreEqual(newResolution, after.LightmapResolution, 0.001f, "lightmapResolution should round-trip");
}
finally
{
// Restore the prior values so the suite leaves no residue in the host project's
// lighting settings (the active settings may be a shared scene-default or asset).
LightingBakeCommands.SetLightingSettings(new JObject
{
["bounces"] = before.Bounces,
["lightmapResolution"] = before.LightmapResolution
});
}
}
[Test]
public void SetLightingSettings_ReportsUnknownKeys()
{
var settings = new JObject
{
["bounces"] = 2,
["totallyNotARealKey"] = 5
};
var result = JObject.FromObject(LightingBakeCommands.SetLightingSettings(settings));
var applied = result["applied"].Select(t => t.Value<string>()).ToList();
var unknown = result["unknown"].Select(t => t.Value<string>()).ToList();
CollectionAssert.Contains(applied, "bounces");
CollectionAssert.Contains(unknown, "totallyNotARealKey");
}
[Test]
public void SetLightingSettings_DryRun_DoesNotChangeAnything()
{
var before = LightingBakeCommands.GetLightingSettings();
var changed = before.Bounces == 1 ? 4 : 1;
LightingBakeCommands.SetLightingSettings(new JObject { ["bounces"] = changed }, dryRun: true);
var after = LightingBakeCommands.GetLightingSettings();
Assert.AreEqual(before.Bounces, after.Bounces, "dry_run must not mutate settings");
}
[Test]
public void ClearBakedLighting_WithoutConfirm_Throws()
{
Assert.Throws<System.ArgumentException>(() => LightingBakeCommands.ClearBakedLighting());
}
[Test]
public void ClearBakedLighting_DryRun_DoesNotThrow_AndReportsWouldClear()
{
var result = JObject.FromObject(LightingBakeCommands.ClearBakedLighting(confirm: true, dryRun: true));
Assert.AreEqual("dry_run", result["status"].Value<string>());
Assert.IsTrue(result["wouldClear"].Value<bool>());
}
[Test]
public void BakeLighting_NoOpenSavedScene_ReturnsNoScene()
{
// A fresh untitled scene has an empty path, so it is not a bakeable (saved) scene.
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
Assert.IsTrue(string.IsNullOrEmpty(SceneManager.GetActiveScene().path), "test precondition: untitled scene");
var result = JObject.FromObject(LightingBakeCommands.BakeLighting());
Assert.AreEqual("no_scene", result["code"].Value<string>());
}
[Test]
public void LightingBakeStatus_Idle_WhenNothingTriggered_OrAfterStatusFile()
{
// The status reflects either a clean idle or the result of a prior (cancelled/completed)
// bake from another test; in all cases it must be valid JSON carrying a "status" field.
var json = JObject.Parse(LightingBakeCommands.LightingBakeStatus());
Assert.IsNotNull(json["status"], "lighting_bake_status should always include a 'status' field");
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: bdeb4ada982746e1aa5d72b49157fd3e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,125 @@
using System.Linq;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor;
using Unity.Pipeline.Editor.Commands.Baking;
using UnityEditor.SceneManagement;
namespace Unity.Pipeline.Tests.Editor.Baking
{
/// <summary>
/// Tests for the CLI-215 legacy NavMesh bake commands. Covers discovery/schema, the settings
/// get/set round-trip, the destructive clear guard, the idle status, the no_scene guard, dry_run
/// being a no-op, and the AI-Navigation stub returning package_not_found when the package is
/// absent. The full bake → succeeded flow is exercised live.
/// </summary>
class NavMeshBakeCommandsTests
{
[SetUp]
public void SetUp() => CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
[TearDown]
public void TearDown()
{
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
}
[Test]
public void NavMeshCommands_AreDiscovered_WithExpectedSchema()
{
var commands = CommandRegistry.DiscoverCommands().ToList();
Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "bake_navmesh"));
Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "cancel_navmesh_bake"));
Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "clear_navmesh"));
Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "get_navmesh_settings"));
Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "set_navmesh_settings"));
var status = commands.FirstOrDefault(c => c.Name == "navmesh_bake_status");
Assert.IsNotNull(status, "Should discover navmesh_bake_status");
Assert.IsFalse(status.MainThreadRequired, "navmesh_bake_status must be off-main-thread");
Assert.AreEqual(0, status.Parameters.Count, "navmesh_bake_status takes no parameters");
}
[Test]
public void GetNavMeshSettings_ReturnsAgentFields()
{
var settings = NavMeshBakeCommands.GetNavMeshSettings();
Assert.IsTrue(settings.Available, "Legacy NavMesh settings should be available");
Assert.Greater(settings.AgentRadius, 0f, "agentRadius should be a sensible positive default");
Assert.Greater(settings.AgentHeight, 0f, "agentHeight should be a sensible positive default");
}
[Test]
public void SetNavMeshSettings_RoundTrips_AgentRadius()
{
var before = NavMeshBakeCommands.GetNavMeshSettings();
var newRadius = before.AgentRadius >= 0.5f ? 0.35f : 0.75f;
NavMeshBakeCommands.SetNavMeshSettings(new JObject { ["agentRadius"] = newRadius });
var after = NavMeshBakeCommands.GetNavMeshSettings();
Assert.AreEqual(newRadius, after.AgentRadius, 0.001f, "agentRadius should round-trip");
// Restore the prior value so the suite leaves no residue in the host project's settings.
NavMeshBakeCommands.SetNavMeshSettings(new JObject { ["agentRadius"] = before.AgentRadius });
}
[Test]
public void SetNavMeshSettings_ReportsUnknownKeys()
{
var result = JObject.FromObject(
NavMeshBakeCommands.SetNavMeshSettings(new JObject { ["agentRadius"] = 0.5f, ["nope"] = 1 }, dryRun: true));
var applied = result["applied"].Select(t => t.Value<string>()).ToList();
var unknown = result["unknown"].Select(t => t.Value<string>()).ToList();
CollectionAssert.Contains(applied, "agentRadius");
CollectionAssert.Contains(unknown, "nope");
}
[Test]
public void SetNavMeshSettings_DryRun_DoesNotChangeAnything()
{
var before = NavMeshBakeCommands.GetNavMeshSettings();
var changed = before.AgentRadius >= 0.5f ? 0.2f : 0.9f;
NavMeshBakeCommands.SetNavMeshSettings(new JObject { ["agentRadius"] = changed }, dryRun: true);
var after = NavMeshBakeCommands.GetNavMeshSettings();
Assert.AreEqual(before.AgentRadius, after.AgentRadius, 0.001f, "dry_run must not mutate settings");
}
[Test]
public void ClearNavMesh_WithoutConfirm_Throws()
{
Assert.Throws<System.ArgumentException>(() => NavMeshBakeCommands.ClearNavMesh());
}
[Test]
public void BakeNavMesh_NoOpenSavedScene_ReturnsNoScene()
{
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
var result = JObject.FromObject(NavMeshBakeCommands.BakeNavMesh());
Assert.AreEqual("no_scene", result["code"].Value<string>());
}
[Test]
public void NavMeshBakeStatus_AlwaysHasStatusField()
{
var json = JObject.Parse(NavMeshBakeCommands.NavMeshBakeStatus());
Assert.IsNotNull(json["status"]);
}
[Test]
public void BakeNavMeshSurfaces_WithoutPackage_ReturnsPackageNotFound()
{
// This repo does not reference com.unity.ai.navigation, so the stub should report the
// package is absent (or, if the package is present in the host project, not_implemented).
var result = JObject.FromObject(NavMeshBakeCommands.BakeNavMeshSurfaces());
var code = result["code"].Value<string>();
Assert.That(code, Is.EqualTo("package_not_found").Or.EqualTo("not_implemented"));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d88fed4842cc4242b9e25237547c5514
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,69 @@
using System.Linq;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor;
using Unity.Pipeline.Editor.Commands.Baking;
using UnityEditor.SceneManagement;
namespace Unity.Pipeline.Tests.Editor.Baking
{
/// <summary>
/// Tests for the CLI-215 occlusion-culling bake commands. Covers discovery/schema, the destructive
/// clear guard, the idle status, the no_scene guard, and dry_run being a no-op (and echoing the
/// effective parameters). The full bake → succeeded flow is exercised live.
/// </summary>
class OcclusionBakeCommandsTests
{
[SetUp]
public void SetUp() => CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
[TearDown]
public void TearDown()
{
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
}
[Test]
public void OcclusionCommands_AreDiscovered_WithExpectedSchema()
{
var commands = CommandRegistry.DiscoverCommands().ToList();
var bake = commands.FirstOrDefault(c => c.Name == "bake_occlusion_culling");
Assert.IsNotNull(bake, "Should discover bake_occlusion_culling");
CollectionAssert.AreEquivalent(
new[] { "smallest_occluder", "smallest_hole", "backface_threshold", "confirm", "dry_run" },
bake.Parameters.Select(p => p.Name).ToList());
var status = commands.FirstOrDefault(c => c.Name == "occlusion_bake_status");
Assert.IsNotNull(status, "Should discover occlusion_bake_status");
Assert.IsFalse(status.MainThreadRequired, "occlusion_bake_status must be off-main-thread");
Assert.AreEqual(0, status.Parameters.Count, "occlusion_bake_status takes no parameters");
Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "cancel_occlusion_bake"));
Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "clear_occlusion_culling"));
}
[Test]
public void ClearOcclusion_WithoutConfirm_Throws()
{
Assert.Throws<System.ArgumentException>(() => OcclusionBakeCommands.ClearOcclusionCulling());
}
[Test]
public void BakeOcclusion_NoOpenSavedScene_ReturnsNoScene()
{
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
var result = JObject.FromObject(OcclusionBakeCommands.BakeOcclusionCulling());
Assert.AreEqual("no_scene", result["code"].Value<string>());
}
[Test]
public void OcclusionBakeStatus_AlwaysHasStatusField()
{
var json = JObject.Parse(OcclusionBakeCommands.OcclusionBakeStatus());
Assert.IsNotNull(json["status"]);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f65c8110a95f44cbbf3aaf0d9832aa0e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1f0ae5fba45d4e60a0bfc82f77f35069
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,958 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
using NUnit.Framework;
using Newtonsoft.Json.Linq;
using Unity.Pipeline;
using Unity.Pipeline.Editor.Commands.Batch;
using Unity.Pipeline.Editor.Commands.GameObjects;
using UnityEditor;
using UnityEngine;
using UnityEngine.TestTools;
using Object = UnityEngine.Object;
namespace Unity.Pipeline.Tests.Editor.Batch
{
/// <summary>
/// Tests for the transactional <c>batch</c> command (AUTHAPI-27), driven end-to-end through an
/// isolated <see cref="PipelineTestServer"/> (the ViaClient pattern). Covers the acceptance
/// criteria: cross-op references resolve, a mid-batch failure atomically rolls back every prior
/// op, a successful batch collapses into a single Undo group, dry-run validates without mutating,
/// a bad reference errors clearly, <c>on_error=continue</c> applies independent ops without
/// reverting, and the 200-op cap is enforced.
/// </summary>
class BatchCommandTests
{
private readonly List<GameObject> m_Spawned = new List<GameObject>();
private GameObject Track(GameObject go)
{
if (go != null)
m_Spawned.Add(go);
return go;
}
private static ObjectId? InstanceIdOf(JToken opResult) =>
opResult?["result"]?["instanceId"]?.ToObject<ObjectId?>();
private GameObject ResolveGameObject(JToken opResult)
{
var id = InstanceIdOf(opResult);
if (!id.HasValue)
return null;
return Track(PipelineUtils.IdToObject(id.Value) as GameObject);
}
[TearDown]
public void TearDown()
{
foreach (var go in m_Spawned)
{
if (go != null)
Object.DestroyImmediate(go);
}
m_Spawned.Clear();
// Deliberately NO Undo.ClearAll() here: this suite runs inside the dogfood editor, and
// wiping the global Undo stack would destroy the developer's own undo history. Per-test
// state is cleaned up by destroying m_Spawned above; the batch Undo groups left on the
// stack only reference those (now destroyed) test objects and are inert.
}
private static JObject Op(string command, JObject parameters = null, string id = null)
{
var op = new JObject { ["command"] = command };
if (id != null)
op["id"] = id;
op["params"] = parameters ?? new JObject();
return op;
}
[Test]
public void Batch_CrossOpReferences_ResolveAndApply()
{
// AC: create_gameobject -> add_component -> set_serialized_field, where later ops use
// $0.instanceId / $1.instanceId, executes in one round-trip with references resolved.
using (var server = new PipelineTestServer())
{
var operations = new JArray
{
Op("create_gameobject", new JObject { ["name"] = "BatchRef_Root" }),
Op("add_component", new JObject { ["target"] = "$0.instanceId", ["type"] = "Rigidbody" }),
Op("set_serialized_field", new JObject
{
["target"] = "$1.instanceId",
["field"] = "m_Mass",
["value"] = new JValue(2.5f)
}),
};
var response = server.Execute("batch", new JObject { ["operations"] = operations });
Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}");
var batch = response.JsonResponse["result"];
Assert.AreEqual(3, batch["applied"]?.ToObject<int>(), "all three ops should apply");
Assert.IsFalse(batch["reverted"]?.ToObject<bool>() ?? true, "a successful batch must not revert");
Assert.IsNotNull(batch["undoGroup"], "execution result should carry the batch Undo group");
var results = batch["results"] as JArray;
Assert.AreEqual(3, results.Count);
for (int i = 0; i < 3; i++)
Assert.IsTrue(results[i]["success"]?.ToObject<bool>() ?? false,
$"op {i} should succeed: {results[i]["error"]}");
// The reference chain wired a Rigidbody onto op 0's GameObject and set its mass.
var root = ResolveGameObject(results[0]);
Assert.IsNotNull(root, "op 0's GameObject should be resolvable from its returned instanceId");
var rb = root.GetComponent<Rigidbody>();
Assert.IsNotNull(rb, "$0.instanceId must resolve so add_component targets op 0's GameObject");
Assert.AreEqual(2.5f, rb.mass, 0.0001f,
"$1.instanceId must resolve so set_serialized_field targets the added Rigidbody");
}
}
[Test]
public void Batch_Transactional_MidBatchFailure_RevertsAllPriorOps()
{
// AC: an op fails mid-batch -> every prior op is reverted; scene equals pre-batch state.
using (var server = new PipelineTestServer())
{
var operations = new JArray
{
Op("create_gameobject", new JObject { ["name"] = "BatchRollback_A" }),
Op("create_gameobject", new JObject { ["name"] = "BatchRollback_B" }),
// Fails: unknown component type -> add_component throws before mutating.
Op("add_component", new JObject { ["target"] = "$0.instanceId", ["type"] = "NoSuchTypeXYZ" }),
};
// The failing sub-op logs an error through the shared command-dispatch path (batch
// reuses it); the batch surfaces the failure structurally, but the log still fires.
LogAssert.Expect(LogType.Error, new Regex("add_component' failed: Could not resolve component type 'NoSuchTypeXYZ'"));
var response = server.Execute("batch", new JObject { ["operations"] = operations });
Assert.IsTrue(response.IsSuccess, $"batch itself should return HTTP success: {response.RawResponse}");
var batch = response.JsonResponse["result"];
Assert.IsTrue(batch["reverted"]?.ToObject<bool>() ?? false, "a transactional failure must revert");
Assert.AreEqual(2, batch["applied"]?.ToObject<int>(), "two ops ran before the failure");
var results = batch["results"] as JArray;
Assert.IsTrue(results[0]["success"]?.ToObject<bool>() ?? false);
Assert.IsTrue(results[1]["success"]?.ToObject<bool>() ?? false);
Assert.IsFalse(results[2]["success"]?.ToObject<bool>() ?? true, "op 2 must fail");
StringAssert.Contains("component type", results[2]["error"]?.ToString(),
"the failing op's error should be reported");
// The two created objects must be gone (reverted), both by name and by their ids.
Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchRollback_A").Count,
"op 0's object must be reverted");
Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchRollback_B").Count,
"op 1's object must be reverted");
var id0 = InstanceIdOf(results[0]);
var id1 = InstanceIdOf(results[1]);
Assert.IsTrue(id0.HasValue && id1.HasValue, "reverted ops still report the ids they had created");
Assert.IsNull(PipelineUtils.IdToObject(id0.Value), "op 0's object should no longer resolve");
Assert.IsNull(PipelineUtils.IdToObject(id1.Value), "op 1's object should no longer resolve");
}
}
[Test]
public void Batch_SuccessfulBatch_CollapsesIntoSingleUndoGroup()
{
// AC: a successful batch shows exactly one Undo group -> a single Undo reverts the whole batch.
using (var server = new PipelineTestServer())
{
var operations = new JArray
{
Op("create_gameobject", new JObject { ["name"] = "BatchUndo_A" }),
Op("create_gameobject", new JObject { ["name"] = "BatchUndo_B" }),
};
var response = server.Execute("batch", new JObject { ["operations"] = operations });
Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}");
// Track the created objects so TearDown cleans them up even if the Undo below misbehaves.
var results = response.JsonResponse["result"]["results"] as JArray;
ResolveGameObject(results[0]);
ResolveGameObject(results[1]);
Assert.AreEqual(1, GameObjectCommands.FindGameObjects(name: "BatchUndo_A").Count);
Assert.AreEqual(1, GameObjectCommands.FindGameObjects(name: "BatchUndo_B").Count);
// One Undo must revert BOTH creations, proving the whole batch collapsed into one group.
Undo.PerformUndo();
Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchUndo_A").Count,
"a single Undo should revert the first op (batch is one group)");
Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchUndo_B").Count,
"a single Undo should revert the second op too (batch is one group)");
}
}
[Test]
public void Batch_DryRun_CatchesErrors_AndMutatesNothing()
{
// AC: dry_run catches unknown command, not_batchable, unknown param, and a bad reference,
// with zero mutation.
using (var server = new PipelineTestServer())
{
var operations = new JArray
{
Op("create_gameobject", new JObject { ["name"] = "BatchDry_A" }), // valid
Op("no_such_command_xyz"), // unknown command
Op("build"), // not_batchable
Op("create_gameobject", new JObject { ["bogus_param"] = "x" }), // unknown parameter
Op("add_component", new JObject { ["target"] = "$9.instanceId", ["type"] = "Rigidbody" }), // bad ref
};
var response = server.Execute("batch", new JObject
{
["operations"] = operations,
["dry_run"] = true
});
Assert.IsTrue(response.IsSuccess, $"dry_run should return HTTP success: {response.RawResponse}");
var batch = response.JsonResponse["result"];
Assert.IsTrue(batch["dryRun"]?.ToObject<bool>() ?? false, "result should be marked dryRun");
Assert.IsFalse(batch["valid"]?.ToObject<bool>() ?? true, "the batch has invalid ops");
Assert.AreEqual(0, batch["applied"]?.ToObject<int>() ?? -1, "dry_run applies nothing");
var results = batch["results"] as JArray;
Assert.IsTrue(results[0]["success"]?.ToObject<bool>() ?? false, "op 0 is valid");
StringAssert.Contains("unknown command", results[1]["error"]?.ToString());
StringAssert.Contains("not_batchable", results[2]["error"]?.ToString());
StringAssert.Contains("unknown parameter", results[3]["error"]?.ToString());
StringAssert.Contains("unknown operation", results[4]["error"]?.ToString());
// Zero mutation: the valid create op must NOT have run.
Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchDry_A").Count,
"dry_run must not create anything");
}
}
[Test]
public void Batch_BadReference_ErrorsClearly_AndReverts()
{
// AC: a bad reference errors clearly. Here op 1 references a nonexistent op; under the
// default transactional/abort policy op 0 is reverted.
using (var server = new PipelineTestServer())
{
var operations = new JArray
{
Op("create_gameobject", new JObject { ["name"] = "BatchBadRef_X" }),
Op("add_component", new JObject { ["target"] = "$5.instanceId", ["type"] = "Rigidbody" }),
};
var response = server.Execute("batch", new JObject { ["operations"] = operations });
Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}");
var batch = response.JsonResponse["result"];
var results = batch["results"] as JArray;
Assert.IsFalse(results[1]["success"]?.ToObject<bool>() ?? true, "the referencing op must fail");
var error = results[1]["error"]?.ToString();
StringAssert.Contains("$5.instanceId", error, "the error should name the offending reference");
StringAssert.Contains("unknown operation", error, "and explain why it is invalid");
Assert.IsTrue(batch["reverted"]?.ToObject<bool>() ?? false, "the prior op must be reverted");
Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchBadRef_X").Count,
"op 0 must be reverted after the bad reference");
}
}
[Test]
public void Batch_OnErrorContinue_AppliesIndependentOps_NoRevert()
{
// AC: on_error=continue collects failures, applies independent ops, reports reverted:false.
using (var server = new PipelineTestServer())
{
var operations = new JArray
{
Op("create_gameobject", new JObject { ["name"] = "BatchCont_A" }),
Op("add_component", new JObject { ["target"] = "$0.instanceId", ["type"] = "NoSuchTypeXYZ" }), // fails
Op("create_gameobject", new JObject { ["name"] = "BatchCont_C" }), // independent
};
LogAssert.Expect(LogType.Error, new Regex("add_component' failed: Could not resolve component type 'NoSuchTypeXYZ'"));
var response = server.Execute("batch", new JObject
{
["operations"] = operations,
["on_error"] = "continue"
});
Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}");
var batch = response.JsonResponse["result"];
Assert.IsFalse(batch["reverted"]?.ToObject<bool>() ?? true, "continue must not revert");
Assert.IsFalse(batch["transactional"]?.ToObject<bool>() ?? true, "continue forces transactional=false");
Assert.AreEqual(2, batch["applied"]?.ToObject<int>(), "the two independent creates apply");
var results = batch["results"] as JArray;
Assert.IsTrue(results[0]["success"]?.ToObject<bool>() ?? false);
Assert.IsFalse(results[1]["success"]?.ToObject<bool>() ?? true, "the middle op fails");
Assert.IsTrue(results[2]["success"]?.ToObject<bool>() ?? false, "a later independent op still runs");
// Both independent objects must survive (no rollback).
Assert.IsNotNull(ResolveGameObject(results[0]), "op 0's object should survive");
Assert.IsNotNull(ResolveGameObject(results[2]), "op 2's object should survive");
}
}
[Test]
public void Batch_ExceedsOperationCap_ReturnsError()
{
// AC: the 200-op cap is enforced with a structured error (checked before anything runs).
using (var server = new PipelineTestServer())
{
var operations = new JArray();
for (int i = 0; i < 201; i++)
operations.Add(Op("create_gameobject", new JObject { ["name"] = "BatchCap_" + i }));
LogAssert.Expect(LogType.Error, new Regex("batch supports at most 200 operations"));
var response = server.Execute("batch", new JObject
{
["operations"] = operations,
["dry_run"] = true
});
Assert.IsFalse(response.IsSuccess, "a batch over the op cap must be rejected");
StringAssert.Contains("200", response.RawResponse, "the error should state the 200-op cap");
Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchCap_0").Count,
"an over-cap batch must create nothing");
}
}
[Test]
public void Batch_AsyncCommand_NotBatchable_AtDryRunAndExecution()
{
// C1: run_tests/list_tests are async Task commands completed by EditorApplication.update
// callbacks, which cannot fire while the batch blocks the main thread — awaiting them
// inline would freeze the editor permanently. They must be rejected, not dispatched.
using (var server = new PipelineTestServer())
{
var operations = new JArray
{
Op("run_tests", new JObject { ["mode"] = "EditMode" }),
Op("list_tests"),
};
// Dry run flags both without executing anything.
var response = server.Execute("batch", new JObject
{
["operations"] = operations,
["dry_run"] = true
});
Assert.IsTrue(response.IsSuccess, $"dry_run should return HTTP success: {response.RawResponse}");
var results = response.JsonResponse["result"]["results"] as JArray;
foreach (var opResult in results)
{
Assert.IsFalse(opResult["success"]?.ToObject<bool>() ?? true, "async ops must fail validation");
StringAssert.Contains("not_batchable", opResult["error"]?.ToString());
StringAssert.Contains("async", opResult["error"]?.ToString());
}
// Execution rejects them the same way — and returns instead of hanging.
response = server.Execute("batch", new JObject { ["operations"] = new JArray { Op("run_tests") } });
Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}");
var exec = response.JsonResponse["result"];
Assert.AreEqual(0, exec["applied"]?.ToObject<int>(), "nothing must be dispatched");
StringAssert.Contains("not_batchable", exec["results"][0]["error"]?.ToString());
}
}
[Test]
public void Batch_Transactional_RejectsNonRevertibleCommand_NonTransactionalAllowsIt()
{
// C2: delete_asset mutates the AssetDatabase, which Undo cannot roll back — inside a
// transactional batch "reverted: true" would be a lie, so it is rejected. With
// transactional:false it runs and reports revertible:false.
const string assetPath = "Assets/BatchTests_DeleteTarget.txt";
File.WriteAllText(assetPath, "batch delete target");
AssetDatabase.ImportAsset(assetPath);
try
{
using (var server = new PipelineTestServer())
{
var operations = new JArray
{
Op("delete_asset", new JObject { ["asset"] = assetPath, ["confirm"] = true }),
};
// Dry run under the default transactional=true flags it.
var response = server.Execute("batch", new JObject
{
["operations"] = operations,
["dry_run"] = true
});
var dry = response.JsonResponse["result"];
Assert.IsFalse(dry["valid"]?.ToObject<bool>() ?? true);
StringAssert.Contains("not_batchable_transactional", dry["results"][0]["error"]?.ToString());
// Execution under transactional=true rejects it too; the asset survives.
response = server.Execute("batch", new JObject { ["operations"] = operations });
var exec = response.JsonResponse["result"];
Assert.IsFalse(exec["results"][0]["success"]?.ToObject<bool>() ?? true);
StringAssert.Contains("not_batchable_transactional", exec["results"][0]["error"]?.ToString());
Assert.IsNotNull(AssetDatabase.LoadMainAssetAtPath(assetPath),
"a rejected delete_asset must not touch the asset");
// transactional:false allows it — with an honest revertible:false on the op.
response = server.Execute("batch", new JObject
{
["operations"] = operations,
["transactional"] = false
});
exec = response.JsonResponse["result"];
Assert.IsTrue(exec["results"][0]["success"]?.ToObject<bool>() ?? false,
$"transactional:false must allow delete_asset: {exec["results"][0]["error"]}");
Assert.IsFalse(exec["results"][0]["revertible"]?.ToObject<bool>() ?? true,
"the op must report revertible:false");
Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(assetPath), "the asset must be deleted");
}
}
finally
{
if (AssetDatabase.LoadMainAssetAtPath(assetPath) != null)
AssetDatabase.DeleteAsset(assetPath);
}
}
[Test]
public void Batch_OpenScene_AlwaysRejected_EvenNonTransactional()
{
// C2: open_scene/create_scene CLEAR the Editor Undo stack, destroying the batch's ability
// to revert anything before them — they are rejected regardless of the transactional flag.
using (var server = new PipelineTestServer())
{
var operations = new JArray
{
Op("open_scene", new JObject { ["path"] = "Assets/Nope.unity" }),
};
var response = server.Execute("batch", new JObject
{
["operations"] = operations,
["dry_run"] = true
});
var dry = response.JsonResponse["result"];
StringAssert.Contains("not_batchable", dry["results"][0]["error"]?.ToString());
StringAssert.Contains("Undo stack", dry["results"][0]["error"]?.ToString());
response = server.Execute("batch", new JObject
{
["operations"] = operations,
["transactional"] = false
});
var exec = response.JsonResponse["result"];
Assert.IsFalse(exec["results"][0]["success"]?.ToObject<bool>() ?? true);
StringAssert.Contains("not_batchable", exec["results"][0]["error"]?.ToString());
}
}
[Test]
public void Batch_FirstOpPartialMutation_IsRolledBack()
{
// I1: an op that mutates and THEN throws leaves partial Undo-registered state even when
// it is the first (and only) op — the transactional rollback must run with applied == 0.
// set_transform applies 'position' before it parses the invalid 'rotation'.
using (var server = new PipelineTestServer())
{
var go = Track(new GameObject("BatchPartial_Target"));
go.transform.localPosition = Vector3.zero;
var operations = new JArray
{
Op("set_transform", new JObject
{
// Version-stable id: GetInstanceID() is obsolete-as-error from 6000.4.
["target"] = PipelineUtils.GetObjectId(go).RawValue,
["position"] = new JArray(5f, 6f, 7f),
["rotation"] = new JArray(1f, 2f), // invalid: 2 components -> throws after position applied
}),
};
LogAssert.Expect(LogType.Error, new Regex("set_transform' failed: 'rotation' must have exactly 3 components"));
var response = server.Execute("batch", new JObject { ["operations"] = operations });
Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}");
var batch = response.JsonResponse["result"];
Assert.AreEqual(0, batch["applied"]?.ToObject<int>(), "the only op failed");
Assert.IsTrue(batch["reverted"]?.ToObject<bool>() ?? false,
"a transactional failure must revert even when applied == 0");
Assert.AreEqual(0f, go.transform.localPosition.magnitude, 0.0001f,
"the failing op's partial position change must be reverted");
}
}
[Test]
public void Batch_TimeBudgetExceeded_SkipsRemainingOps_AndRevertsTransactional()
{
// I2: the cooperative time budget is checked before each op after the first. Budget 0 is
// exhausted immediately after op 0, so ops 1..n are skipped and the transactional batch
// rolls back with a batch-level error.
using (var server = new PipelineTestServer())
{
var operations = new JArray
{
Op("create_gameobject", new JObject { ["name"] = "BatchBudget_A" }),
Op("create_gameobject", new JObject { ["name"] = "BatchBudget_B" }),
Op("create_gameobject", new JObject { ["name"] = "BatchBudget_C" }),
};
var response = server.Execute("batch", new JObject
{
["operations"] = operations,
["time_budget_ms"] = 0
});
Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}");
var batch = response.JsonResponse["result"];
StringAssert.Contains("batch time budget exceeded", batch["error"]?.ToString());
Assert.AreEqual(1, batch["applied"]?.ToObject<int>(), "only op 0 runs on a zero budget");
Assert.IsTrue(batch["reverted"]?.ToObject<bool>() ?? false, "budget abort is a transactional failure");
var results = batch["results"] as JArray;
Assert.AreEqual(3, results.Count);
Assert.IsTrue(results[0]["success"]?.ToObject<bool>() ?? false, "op 0 always gets to run");
Assert.IsTrue(results[1]["skipped"]?.ToObject<bool>() ?? false, "op 1 must be skipped");
Assert.IsTrue(results[2]["skipped"]?.ToObject<bool>() ?? false, "op 2 must be skipped");
Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchBudget_A").Count,
"op 0 must be reverted by the budget-abort rollback");
Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchBudget_B").Count);
}
}
[Test]
public void Batch_ReferenceToFailedOp_ErrorsClearly()
{
// I3: under on_error=continue an op can fail without stopping the batch; a later $ref
// into the failed op must error explicitly instead of silently resolving to null.
using (var server = new PipelineTestServer())
{
var operations = new JArray
{
Op("add_component", new JObject { ["target"] = "/NoSuchObjectXYZ_00", ["type"] = "Rigidbody" }), // fails
Op("rename_gameobject", new JObject { ["target"] = "$0.instanceId", ["name"] = "Renamed" }),
};
LogAssert.Expect(LogType.Error, new Regex("add_component' failed:"));
var response = server.Execute("batch", new JObject
{
["operations"] = operations,
["on_error"] = "continue"
});
Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}");
var results = response.JsonResponse["result"]["results"] as JArray;
Assert.IsFalse(results[0]["success"]?.ToObject<bool>() ?? true, "op 0 must fail");
Assert.IsFalse(results[1]["success"]?.ToObject<bool>() ?? true, "the referencing op must fail");
var error = results[1]["error"]?.ToString();
StringAssert.Contains("$0.instanceId", error, "the error should name the offending reference");
StringAssert.Contains("failed; its result cannot be referenced", error,
"and say the referenced op failed");
}
}
[Test]
public void Resolver_ReferenceToVoidResult_Errors_WholeValueAndPath()
{
// I3: a SUCCEEDED op with a null result (void command) is distinct from a failed op —
// referencing it errors with "returned no result", for whole-value and path refs alike.
var operations = new List<BatchOperationInput>
{
new BatchOperationInput { Command = "noop_a" },
new BatchOperationInput { Command = "noop_b" },
};
var outcomes = new List<BatchOperationResult>
{
new BatchOperationResult { Command = "noop_a", Success = true },
new BatchOperationResult { Command = "noop_b" },
};
var results = new JToken[] { JValue.CreateNull(), null };
var idToIndex = new Dictionary<string, int>();
var wholeRef = new JObject { ["value"] = "$0" };
var ex = Assert.Throws<ArgumentException>(() =>
BatchReferenceResolver.Resolve(wholeRef, 1, operations, idToIndex, results, outcomes));
StringAssert.Contains("returned no result", ex.Message);
var pathRef = new JObject { ["value"] = "$0.instanceId" };
ex = Assert.Throws<ArgumentException>(() =>
BatchReferenceResolver.Resolve(pathRef, 1, operations, idToIndex, results, outcomes));
StringAssert.Contains("returned no result", ex.Message);
}
[Test]
public void Resolver_ReferenceToFailedOp_Errors_WholeValueAndPath()
{
// I3: whole-value refs into a FAILED op must error just like path refs (the end-to-end
// test above covers the path form; this pins the whole-value form).
var operations = new List<BatchOperationInput>
{
new BatchOperationInput { Command = "noop_a" },
new BatchOperationInput { Command = "noop_b" },
};
var outcomes = new List<BatchOperationResult>
{
new BatchOperationResult { Command = "noop_a", Success = false, Error = "boom" },
new BatchOperationResult { Command = "noop_b" },
};
var results = new JToken[] { null, null };
var idToIndex = new Dictionary<string, int>();
var wholeRef = new JObject { ["value"] = "$0" };
var ex = Assert.Throws<ArgumentException>(() =>
BatchReferenceResolver.Resolve(wholeRef, 1, operations, idToIndex, results, outcomes));
StringAssert.Contains("failed; its result cannot be referenced", ex.Message);
}
[Test]
public void Batch_RuntimeOnlyCommand_NotBatchable()
{
// I5: runtime-only commands (Player server surface) are discoverable via the registry but
// must not be dispatchable through an editor-server batch.
using (var server = new PipelineTestServer())
{
var response = server.Execute("batch", new JObject
{
["operations"] = new JArray { Op("set_timescale", new JObject { ["scale"] = 0.5f }) },
["dry_run"] = true
});
var dry = response.JsonResponse["result"];
Assert.IsFalse(dry["valid"]?.ToObject<bool>() ?? true);
var error = dry["results"][0]["error"]?.ToString();
StringAssert.Contains("not_batchable", error);
StringAssert.Contains("runtime-only", error);
}
}
[Test]
public void Batch_MenuCommand_NotBatchable()
{
// I6: menu items can open modal dialogs, which would wedge an unattended batch.
using (var server = new PipelineTestServer())
{
var response = server.Execute("batch", new JObject
{
["operations"] = new JArray { Op("menu", new JObject { ["path"] = "File/Save" }) },
["dry_run"] = true
});
var dry = response.JsonResponse["result"];
Assert.IsFalse(dry["valid"]?.ToObject<bool>() ?? true);
StringAssert.Contains("not_batchable", dry["results"][0]["error"]?.ToString());
}
}
[Test]
public void Batch_NumericOpId_Rejected()
{
// M3: a purely numeric id would be ambiguous with a 0-based index selector in "$<id-or-index>".
using (var server = new PipelineTestServer())
{
var operations = new JArray
{
Op("create_gameobject", new JObject { ["name"] = "BatchId_A" }, id: "0"),
};
LogAssert.Expect(LogType.Error, new Regex("ids must match"));
var response = server.Execute("batch", new JObject
{
["operations"] = operations,
["dry_run"] = true
});
Assert.IsFalse(response.IsSuccess, "a numeric op id must be rejected outright");
StringAssert.Contains("ids must match", response.RawResponse);
}
}
[Test]
public void Batch_DollarDollar_EscapesToLiteralDollar()
{
// M7: "$$..." escapes a literal '$' — the op receives "$literal", not a reference.
using (var server = new PipelineTestServer())
{
var operations = new JArray
{
Op("create_gameobject", new JObject { ["name"] = "$$literal" }),
};
var response = server.Execute("batch", new JObject { ["operations"] = operations });
Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}");
var results = response.JsonResponse["result"]["results"] as JArray;
var go = ResolveGameObject(results[0]);
Assert.IsNotNull(go, "op 0's GameObject should be resolvable");
Assert.AreEqual("$literal", go.name, "\"$$literal\" must unescape to the literal \"$literal\"");
}
}
[Test]
public void Batch_IdBasedReference_Resolves()
{
// M7: references can select the target op by its id, not only by index.
using (var server = new PipelineTestServer())
{
var operations = new JArray
{
Op("create_gameobject", new JObject { ["name"] = "BatchIdRef_Root" }, id: "root"),
Op("add_component", new JObject { ["target"] = "$root.instanceId", ["type"] = "Rigidbody" }),
};
var response = server.Execute("batch", new JObject { ["operations"] = operations });
Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}");
var batch = response.JsonResponse["result"];
Assert.AreEqual(2, batch["applied"]?.ToObject<int>(),
$"both ops should apply: {batch["results"]}");
var root = ResolveGameObject((batch["results"] as JArray)[0]);
Assert.IsNotNull(root);
Assert.IsNotNull(root.GetComponent<Rigidbody>(), "$root.instanceId must resolve by op id");
}
}
[Test]
public void Batch_ResultFields_ProjectsOpResult()
{
// M7: result_fields keeps only the requested result fields for the op (context economy).
using (var server = new PipelineTestServer())
{
var operations = new JArray
{
Op("create_gameobject", new JObject { ["name"] = "BatchProject_A" }),
};
var response = server.Execute("batch", new JObject
{
["operations"] = operations,
["result_fields"] = new JObject { ["0"] = new JArray("instanceId") }
});
Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}");
var results = response.JsonResponse["result"]["results"] as JArray;
var projected = results[0]["result"] as JObject;
Assert.IsNotNull(projected, "the projected result should be an object");
Assert.IsNotNull(projected["instanceId"], "the requested field must be kept");
Assert.IsNull(projected["hierarchyPath"], "unrequested fields must be dropped");
Assert.IsNull(projected["globalId"], "unrequested fields must be dropped");
// Cleanup: the projection kept instanceId, so the object is still resolvable.
Assert.IsNotNull(ResolveGameObject(results[0]));
}
}
[Test]
public void Batch_OversizedOpResult_Truncated_ButReferencesStillResolve()
{
// M7: an op result over the per-op cap is replaced by a truncation marker in the envelope,
// but later $refs resolve against the FULL (untruncated) result.
const string bigPath = "Assets/BatchTests_BigFile.txt";
File.WriteAllText(bigPath, new string('x', 20000)); // > 16 KiB serialized
try
{
using (var server = new PipelineTestServer())
{
var operations = new JArray
{
Op("read_text_file", new JObject { ["path"] = bigPath }),
// References a scalar of the oversized result — must still resolve.
Op("create_gameobject", new JObject { ["name"] = "$0.assetPath" }),
};
var response = server.Execute("batch", new JObject { ["operations"] = operations });
Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}");
var results = response.JsonResponse["result"]["results"] as JArray;
Assert.IsTrue(results[0]["resultTruncated"]?.ToObject<bool>() ?? false,
"the oversized read_text_file result must be truncated");
Assert.IsTrue(results[0]["result"]?["resultTruncated"]?.ToObject<bool>() ?? false,
"the result must be replaced by the truncation marker");
Assert.IsTrue(results[1]["success"]?.ToObject<bool>() ?? false,
$"the referencing op must still succeed: {results[1]["error"]}");
var go = ResolveGameObject(results[1]);
Assert.IsNotNull(go);
Assert.AreEqual(bigPath, go.name, "$0.assetPath must resolve against the untruncated result");
}
}
finally
{
if (File.Exists(bigPath))
File.Delete(bigPath);
if (File.Exists(bigPath + ".meta"))
File.Delete(bigPath + ".meta");
}
}
[Test]
public void Batch_NestedBatch_Rejected()
{
// M7: batch-in-batch is excluded (recursion/auditing hazard).
using (var server = new PipelineTestServer())
{
var response = server.Execute("batch", new JObject
{
["operations"] = new JArray { Op("batch", new JObject { ["operations"] = new JArray() }) },
["dry_run"] = true
});
var dry = response.JsonResponse["result"];
Assert.IsFalse(dry["valid"]?.ToObject<bool>() ?? true);
StringAssert.Contains("not_batchable", dry["results"][0]["error"]?.ToString());
}
}
[Test]
public void Batch_NonTransactional_Abort_KeepsAppliedOps_SkipsRest()
{
// M7: transactional:false + on_error=abort stops at the failing op but keeps what was
// already applied (no rollback), and marks the rest skipped.
using (var server = new PipelineTestServer())
{
var operations = new JArray
{
Op("create_gameobject", new JObject { ["name"] = "BatchNoTx_A" }),
Op("add_component", new JObject { ["target"] = "$0.instanceId", ["type"] = "NoSuchTypeXYZ" }), // fails
Op("create_gameobject", new JObject { ["name"] = "BatchNoTx_C" }),
};
LogAssert.Expect(LogType.Error, new Regex("add_component' failed: Could not resolve component type 'NoSuchTypeXYZ'"));
var response = server.Execute("batch", new JObject
{
["operations"] = operations,
["transactional"] = false
});
Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}");
var batch = response.JsonResponse["result"];
Assert.IsFalse(batch["reverted"]?.ToObject<bool>() ?? true, "transactional:false must not revert");
Assert.AreEqual(1, batch["applied"]?.ToObject<int>());
var results = batch["results"] as JArray;
Assert.IsTrue(results[0]["success"]?.ToObject<bool>() ?? false);
Assert.IsFalse(results[1]["success"]?.ToObject<bool>() ?? true, "op 1 must fail");
Assert.IsTrue(results[2]["skipped"]?.ToObject<bool>() ?? false, "op 2 must be skipped (abort)");
Assert.IsNotNull(ResolveGameObject(results[0]), "op 0's object must survive (no rollback)");
Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchNoTx_C").Count,
"op 2 never ran");
}
}
[Test]
public void Batch_InvalidResultFieldsPath_DoesNotFailTheAppliedOp()
{
// Review finding: a malformed result_fields JSONPath threw AFTER the op had mutated
// state, and the outer catch flipped the op's Success to false — rolling back a
// succeeded op under on_error=abort. The projection must report the bad path instead.
using (var server = new PipelineTestServer())
{
var operations = new JArray { Op("create_gameobject", new JObject { ["name"] = "BatchProj_BadPath" }) };
var response = server.Execute("batch", new JObject
{
["operations"] = operations,
["result_fields"] = new JObject { ["0"] = new JArray("foo[") }
});
Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}");
var batch = response.JsonResponse["result"];
var results = batch["results"] as JArray;
Assert.IsTrue(results[0]["success"]?.ToObject<bool>() ?? false,
"a malformed projection path must not flip an already-applied op to failure");
Assert.IsFalse(batch["reverted"]?.ToObject<bool>() ?? true,
"and must not trigger a rollback of the succeeded op");
StringAssert.Contains("foo[", results[0]["result"]?["invalidResultFields"]?[0]?.ToString(),
"the bad path is reported inside the projection");
var survivors = GameObjectCommands.FindGameObjects(name: "BatchProj_BadPath");
Assert.AreEqual(1, survivors.Count, "the op's mutation must survive");
Track(GameObject.Find("BatchProj_BadPath"));
}
}
[Test]
public void Batch_TransactionalRevert_LeavesUndoStackClean()
{
// Review finding: after Undo.RevertAllDownToGroup discarded the batch group, the
// enclosing AuthoringUndoScope still collapsed that stale group id on dispose. The
// scope is now canceled after a revert; the contract this pins: the next PerformUndo
// must undo the USER's previous operation, not be absorbed by a stray batch group.
using (var server = new PipelineTestServer())
{
Undo.IncrementCurrentGroup();
var marker = Track(new GameObject("BatchUndoStack_Marker"));
Undo.RegisterCreatedObjectUndo(marker, "create marker");
Undo.IncrementCurrentGroup();
var operations = new JArray
{
Op("create_gameobject", new JObject { ["name"] = "BatchUndoStack_Tx" }),
Op("add_component", new JObject { ["target"] = "$0.instanceId", ["type"] = "NoSuchTypeXYZ" }),
};
LogAssert.Expect(LogType.Error, new Regex("add_component' failed: Could not resolve component type 'NoSuchTypeXYZ'"));
var response = server.Execute("batch", new JObject { ["operations"] = operations });
Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}");
Assert.IsTrue(response.JsonResponse["result"]["reverted"]?.ToObject<bool>() ?? false,
"precondition: the transactional batch reverted");
Undo.PerformUndo();
Assert.IsTrue(marker == null,
"PerformUndo after a reverted batch must undo the previous user operation " +
"(the marker creation) — a surviving marker means a stray batch group absorbed it");
}
}
[Test]
public void ProjectResult_MeasuresUtf8Bytes_NotUtf16Chars()
{
// Review finding: the per-op cap compared UTF-16 char counts while the wire is UTF-8 —
// a CJK payload at ~12k chars is ~36 KB on the wire and sailed under the 16 KiB cap.
var op = new BatchOperationInput { Command = "noop" };
var opResult = new BatchOperationResult();
var budget = int.MaxValue; // isolate the per-op cap from the aggregate budget
var cjk = new string('\u597D', 12_000); // 12k chars, ~36 KB UTF-8
var projected = BatchCommand.ProjectResult(cjk, JToken.FromObject(cjk), op, 0, null, opResult, ref budget);
Assert.IsTrue(opResult.ResultTruncated == true,
"a payload under the char count but over the UTF-8 byte cap must be truncated");
var marker = (JObject)JToken.FromObject(projected);
Assert.Greater(marker["length"]?.ToObject<int>() ?? 0, 16 * 1024, "length reports wire bytes");
}
[Test]
public void ProjectResult_AggregateBudget_ElidesLaterResults()
{
// Review finding: nothing bounded the SUM of per-op results (200 ops x 16 KiB each is a
// ~3.2 MB reply). Once the aggregate budget is exhausted, later results become markers.
var op = new BatchOperationInput { Command = "noop" };
var budget = BatchCommand.MaxBatchResultBytes;
var big = new string('a', 15_000); // under the per-op cap; ~17 of these exhaust the budget
object last = null;
BatchOperationResult lastResult = null;
var truncatedAt = -1;
for (var i = 0; i < 30; i++)
{
lastResult = new BatchOperationResult();
last = BatchCommand.ProjectResult(big, JToken.FromObject(big), op, i, null, lastResult, ref budget);
if (lastResult.ResultTruncated == true) { truncatedAt = i; break; }
}
Assert.GreaterOrEqual(truncatedAt, 2, "several results must fit before the budget trips");
Assert.AreNotEqual(-1, truncatedAt, "the aggregate budget must eventually trip");
StringAssert.Contains("aggregate result budget", ((JObject)JToken.FromObject(last))["error"]?.ToString(),
"the marker explains the aggregate elision");
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7dffec4aa4e549679a30d77044ee314d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,105 @@
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor;
using Unity.Pipeline.Editor.Commands.Build;
using UnityEditor;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Tests for the build-configuration commands (CLI-204): <c>list_build_targets</c>,
/// <c>get_build_settings</c>, <c>set_build_settings</c> (dry run only — no real mutation),
/// <c>list_build_profiles</c>, and the <c>switch_build_target</c> command surface. The read-only
/// commands are executed directly on the test (main) thread; nothing here triggers a build or switch.
/// </summary>
class BuildConfigCommandsTests
{
[SetUp]
public void SetUp() => CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
[Test]
public void NewCommands_AreDiscovered()
{
var names = CommandRegistry.DiscoverCommands().Select(c => c.Name).ToList();
foreach (var expected in new[]
{
"list_build_targets", "get_build_settings", "set_build_settings",
"list_build_profiles", "switch_build_target", "switch_build_target_status"
})
{
CollectionAssert.Contains(names, expected, $"Should discover the {expected} command");
}
}
[Test]
public void SwitchBuildTarget_RequiresTarget_AndConfirm()
{
var switchCmd = CommandRegistry.DiscoverCommands().First(c => c.Name == "switch_build_target");
var target = switchCmd.Parameters.First(p => p.Name == "target");
Assert.IsTrue(target.Required, "switch_build_target 'target' must be required");
// Missing confirm must be refused without attempting the switch.
var result = JObjectOf(SwitchBuildTargetCommand.SwitchBuildTarget(
EditorUserBuildSettings.activeBuildTarget == UnityEditor.BuildTarget.StandaloneWindows64
? "StandaloneLinux64"
: "StandaloneWindows64",
confirm: false));
Assert.AreEqual("error", (string)result["status"]);
Assert.AreEqual(false, (bool)result["success"]);
}
[Test]
public void ListBuildTargets_IncludesActiveTarget_AsInstalled()
{
var targets = (List<BuildTargetInfo>)BuildConfigCommands.ListBuildTargets();
Assert.IsNotNull(targets);
Assert.Greater(targets.Count, 0, "should enumerate at least one build target");
var active = EditorUserBuildSettings.activeBuildTarget.ToString();
var entry = targets.FirstOrDefault(t => t.Name == active);
Assert.IsNotNull(entry, "the active build target should appear in the list");
Assert.IsTrue(entry.IsInstalled, "the active build target must be installed");
}
[Test]
public void GetBuildSettings_ReportsActiveTargetAndScenes()
{
var settings = (BuildSettingsResult)BuildConfigCommands.GetBuildSettings();
Assert.IsNotNull(settings);
Assert.AreEqual(EditorUserBuildSettings.activeBuildTarget.ToString(), settings.ActiveBuildTarget);
Assert.IsNotNull(settings.Scenes, "scene list should be present (may be empty)");
Assert.IsNotNull(settings.Il2CppCodeGeneration);
}
[Test]
public void SetBuildSettings_DryRun_ReportsPlan_WithoutMutating()
{
var before = EditorUserBuildSettings.development;
var input = new SetBuildSettingsInput { DevelopmentBuild = !before };
var result = (SetBuildSettingsResult)BuildConfigCommands.SetBuildSettings(input, dryRun: true);
Assert.IsTrue(result.Success);
Assert.IsTrue(result.DryRun);
Assert.IsTrue(result.Applied.ContainsKey("developmentBuild"),
"the flipped field should appear in the dry-run 'applied' plan");
Assert.AreEqual(before, EditorUserBuildSettings.development,
"a dry run must not change EditorUserBuildSettings");
}
[Test]
public void SetBuildSettings_NoSettings_Fails()
{
var result = (SetBuildSettingsResult)BuildConfigCommands.SetBuildSettings(null, dryRun: false);
Assert.IsFalse(result.Success);
}
static Newtonsoft.Json.Linq.JObject JObjectOf(object value) =>
Newtonsoft.Json.Linq.JObject.FromObject(value);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f6dcfbbcfcc4e70428b62c04568efe8b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c83fc76f10b96db43a56fd1bbf79c646
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,428 @@
using System;
using System.Collections;
using System.IO;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Editor.Commands.Capture;
using UnityEditor;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.TestTools;
namespace Unity.Pipeline.Tests.Editor.Capture
{
/// <summary>
/// Tests for the visual-feedback commands (CLI-199), exercised directly and via PipelineClient.
/// Render tests are GPU-gated: under batchmode/headless the graphics device is
/// <see cref="GraphicsDeviceType.Null"/> and the tests self-ignore rather than fail.
/// </summary>
class CaptureCommandsTests
{
private const string CameraName = "CLI199_Cam";
private const string SaveFolder = "Assets/CLI199_CaptureTests";
// PNG file signature: 0x89 'P' 'N' 'G' 0x0D 0x0A 0x1A 0x0A.
private static readonly byte[] PngSignature = { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
private GameObject m_CameraObject;
[SetUp]
public void SetUp()
{
m_CameraObject = new GameObject(CameraName);
m_CameraObject.AddComponent<Camera>();
}
[TearDown]
public void TearDown()
{
if (m_CameraObject != null)
UnityEngine.Object.DestroyImmediate(m_CameraObject);
if (AssetDatabase.IsValidFolder(SaveFolder))
AssetDatabase.DeleteAsset(SaveFolder);
}
private static string AbsolutePath(string projectRelative) =>
Path.Combine(ProjectPaths.ProjectRoot, projectRelative);
private static bool IsHeadless => SystemInfo.graphicsDeviceType == GraphicsDeviceType.Null;
private static void AssertPngSignature(byte[] bytes)
{
Assert.GreaterOrEqual(bytes.Length, PngSignature.Length, "PNG payload too short to contain a signature");
for (var i = 0; i < PngSignature.Length; i++)
Assert.AreEqual(PngSignature[i], bytes[i], $"PNG signature mismatch at byte {i}");
}
#region Direct
[Test]
public void CaptureGameView_NamedCamera_ReturnsPng()
{
if (IsHeadless)
Assert.Ignore("No GPU in batchmode");
var result = CaptureCommands.CaptureGameView(64, 64, CameraName);
Assert.IsNotNull(result, "Capture should return a result");
Assert.IsNotEmpty(result.Base64, "Base64 payload should be non-empty");
Assert.AreEqual(64, result.Width, "Width should match the requested size");
Assert.AreEqual(64, result.Height, "Height should match the requested size");
Assert.AreEqual("png", result.Encoding);
Assert.AreEqual($"camera:{CameraName}", result.Source);
Assert.IsNull(result.SavedPath, "No savePath was requested");
var bytes = Convert.FromBase64String(result.Base64);
AssertPngSignature(bytes);
Assert.AreEqual(bytes.Length, result.Bytes, "Reported byte length should match decoded payload");
}
[Test]
public void CaptureSceneView_ReturnsPng()
{
if (IsHeadless)
Assert.Ignore("No GPU in batchmode");
var sv = SceneView.lastActiveSceneView;
if (sv == null)
Assert.Ignore("No Scene View");
var result = CaptureCommands.CaptureSceneView(64, 64);
Assert.IsNotNull(result, "Capture should return a result");
Assert.IsNotEmpty(result.Base64, "Base64 payload should be non-empty");
Assert.AreEqual("sceneView", result.Source);
var bytes = Convert.FromBase64String(result.Base64);
AssertPngSignature(bytes);
}
[Test]
public void CaptureGameView_SavePath_OmitsBase64_AndWritesPng()
{
if (IsHeadless)
Assert.Ignore("No GPU in batchmode");
var result = CaptureCommands.CaptureGameView(64, 64, CameraName, $"{SaveFolder}/path_only.png");
Assert.IsNull(result.Base64, "save_path without include_inline_image should omit the base64 payload");
Assert.IsNotNull(result.SavedPath, "SavedPath should be set");
var fileBytes = File.ReadAllBytes(AbsolutePath(result.SavedPath));
AssertPngSignature(fileBytes);
Assert.AreEqual(fileBytes.Length, result.Bytes, "Reported byte length should match the file");
}
[Test]
public void CaptureGameView_SavePath_IncludeImage_ReturnsFileAndBase64()
{
if (IsHeadless)
Assert.Ignore("No GPU in batchmode");
var result = CaptureCommands.CaptureGameView(64, 64, CameraName, $"{SaveFolder}/both.png", includeInlineImage: true);
Assert.IsNotEmpty(result.Base64, "include_inline_image=true should return the base64 payload");
Assert.IsNotNull(result.SavedPath, "SavedPath should be set");
AssertPngSignature(Convert.FromBase64String(result.Base64));
AssertPngSignature(File.ReadAllBytes(AbsolutePath(result.SavedPath)));
}
[Test]
public void CaptureGameView_MaxResolution_ClampsInlineRender()
{
if (IsHeadless)
Assert.Ignore("No GPU in batchmode");
// No save_path: the inline image is the only artifact, so the cap applies to the render.
var result = CaptureCommands.CaptureGameView(128, 64, CameraName, maxResolution: 32);
Assert.AreEqual(32, result.Width, "Long edge should be clamped to max_resolution");
Assert.AreEqual(16, result.Height, "Short edge should keep the aspect ratio");
AssertPngSignature(Convert.FromBase64String(result.Base64));
}
[Test]
public void CaptureGameView_SavePath_MaxResolution_DownscalesInlineOnly()
{
if (IsHeadless)
Assert.Ignore("No GPU in batchmode");
var result = CaptureCommands.CaptureGameView(128, 64, CameraName, $"{SaveFolder}/full.png",
includeInlineImage: true, maxResolution: 32);
Assert.AreEqual(128, result.Width, "The saved file keeps the requested resolution");
Assert.AreEqual(32, result.InlineWidth, "The inline copy is downscaled to max_resolution");
Assert.AreEqual(16, result.InlineHeight, "The inline copy keeps the aspect ratio");
AssertPngSignature(Convert.FromBase64String(result.Base64));
AssertPngSignature(File.ReadAllBytes(AbsolutePath(result.SavedPath)));
}
[Test]
public void CaptureSceneView_SavePath_OmitsBase64_AndWritesPng()
{
if (IsHeadless)
Assert.Ignore("No GPU in batchmode");
var sv = SceneView.lastActiveSceneView;
if (sv == null)
Assert.Ignore("No Scene View");
var result = CaptureCommands.CaptureSceneView(64, 64, $"{SaveFolder}/scene_path_only.png");
Assert.IsNull(result.Base64, "save_path without include_inline_image should omit the base64 payload");
Assert.IsNotNull(result.SavedPath, "SavedPath should be set");
AssertPngSignature(File.ReadAllBytes(AbsolutePath(result.SavedPath)));
}
#endregion
#region Source routing (AUTHAPI-10)
[Test]
public void CaptureGameView_ScreenSource_InEditMode_Throws()
{
// Overlay/screen capture is a Play-Mode operation; in Edit Mode it must fail with a clear
// message (this guard runs before the GPU check, so the message is deterministic headless too).
var ex = Assert.Throws<InvalidOperationException>(
() => CaptureCommands.CaptureGameView(64, 64, source: "screen"));
StringAssert.Contains("Play Mode", ex.Message);
}
[Test]
public void CaptureGameView_UnknownSource_Throws()
{
var ex = Assert.Throws<ArgumentException>(
() => CaptureCommands.CaptureGameView(64, 64, source: "bogus"));
StringAssert.Contains("Unknown source", ex.Message);
}
#endregion
#region ViaClient
[Test]
public void CaptureGameView_ViaClient_Succeeds()
{
if (IsHeadless)
Assert.Ignore("No GPU in batchmode");
using (var server = new PipelineTestServer())
{
var response = server.Execute("capture_game_view", new { width = 64, height = 64, camera = CameraName });
Assert.IsTrue(response.IsSuccess, $"capture_game_view should succeed: {response.Error}");
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field");
}
}
[Test]
public void CaptureGameView_ViaClient_SavePath_SerializedResultOmitsBase64Key()
{
if (IsHeadless)
Assert.Ignore("No GPU in batchmode");
using (var server = new PipelineTestServer())
{
var response = server.Execute("capture_game_view",
new { width = 64, height = 64, camera = CameraName, save_path = $"{SaveFolder}/via_client.png" });
Assert.IsTrue(response.IsSuccess, $"capture_game_view should succeed: {response.Error}");
var result = (JObject)response.JsonResponse["result"];
Assert.IsFalse(result.ContainsKey("base64"),
"A path-only result must not carry a base64 key on the wire (AUTHAPI-8)");
Assert.IsNotNull(result["savedPath"]?.ToString(), "savedPath should be present");
}
}
#endregion
#region Screen capture in Play Mode (AUTHAPI-10)
// Draws a full-screen two-tone quad via IMGUI. Like a Screen Space - Overlay canvas it
// composites straight to the screen and never renders through a camera — the case AUTHAPI-10
// covers — so it lets the test prove overlay content shows up in a screen capture but not a
// camera capture, without a uGUI dependency.
//
// The tones are chosen to catch two capture-path bugs a flat saturated color (the old pure
// red) is blind to:
// * MID-GRAYS, because a primary like (255,0,0) is invariant under gamma conversion — 128
// turns into ~188 when the path double-encodes gamma (the washout Thomas reported), so
// asserting captured luminance pins the tone round-trip.
// * TWO DIFFERENT tones (light top, dark bottom), because a uniform screen is invariant
// under vertical flips — CaptureScreenshotIntoRenderTexture returns GPU-UV-oriented
// content that must be un-flipped on D3D/Metal/Vulkan, so asserting which half is which
// pins the orientation.
private static readonly Color32 OverlayTopGray = new Color32(128, 128, 128, 255);
private static readonly Color32 OverlayBottomGray = new Color32(64, 64, 64, 255);
private class FullScreenOverlay : MonoBehaviour
{
private Texture2D m_TopTex;
private Texture2D m_BottomTex;
private static Texture2D Solid(Color32 c)
{
var tex = new Texture2D(1, 1);
tex.SetPixels32(new[] { c });
tex.Apply();
return tex;
}
private void OnGUI()
{
if (m_TopTex == null)
{
m_TopTex = Solid(OverlayTopGray);
m_BottomTex = Solid(OverlayBottomGray);
}
GUI.depth = -1000; // draw on top of any other IMGUI
var half = Screen.height / 2f;
GUI.DrawTexture(new Rect(0, 0, Screen.width, half), m_TopTex);
GUI.DrawTexture(new Rect(0, half, Screen.width, Screen.height - half), m_BottomTex);
}
private void OnDestroy()
{
if (m_TopTex != null) DestroyImmediate(m_TopTex);
if (m_BottomTex != null) DestroyImmediate(m_BottomTex);
}
}
[UnityTearDown]
public IEnumerator ExitPlayModeIfNeeded()
{
// A failed assertion inside the play-mode test would otherwise leave the editor playing.
if (EditorApplication.isPlaying)
yield return new ExitPlayMode();
}
[UnityTest]
public IEnumerator CaptureGameView_ScreenSource_CapturesOverlayUi()
{
// Overlay/screen capture reads the presented game-view backbuffer, which requires a real,
// rendering game view. In pure headless/batchmode there is none (and WaitForEndOfFrame never
// fires), so self-ignore early. Edit-Mode + unknown-source guards cover CI regardless.
if (IsHeadless || Application.isBatchMode)
{
Assert.Ignore("Screen capture requires an interactive game view (skipped headless/batchmode).");
yield break;
}
yield return new EnterPlayMode();
// Tolerate unrelated error logs from whatever project hosts the run (this test only checks pixels).
LogAssert.ignoreFailingMessages = true;
// A camera that clears to blue and renders nothing, so a camera capture is overlay-free.
var camGo = new GameObject("CLI199_ScreenCam");
var cam = camGo.AddComponent<Camera>();
cam.clearFlags = CameraClearFlags.SolidColor;
cam.backgroundColor = Color.blue;
cam.cullingMask = 0;
var overlayGo = new GameObject("CLI199_Overlay");
overlayGo.AddComponent<FullScreenOverlay>();
// Poll a few frames: the overlay only appears in the capture once the game view has
// repainted it into the backbuffer (a single frame is racy in the automated harness).
CaptureResult screen = null;
var overlayObserved = false;
var stats = default((float fraction, float topLuma, float bottomLuma));
for (var frame = 0; frame < 30 && !overlayObserved; frame++)
{
yield return new WaitForEndOfFrame();
screen = CaptureCommands.CaptureGameView(64, 64, source: "screen");
stats = GrayStats(screen.Base64);
// The full-screen overlay should dominate the frame with neutral gray of ANY
// brightness — brightness-agnostic on purpose, so a tone or orientation bug is a
// failed assert below rather than an "overlay not observed" ignore.
overlayObserved = stats.fraction > 0.8f;
}
// Reliable everywhere the screen path runs: it returns a correctly-sized PNG tagged "screen".
Assert.IsNotNull(screen, "screen capture should have run");
Assert.AreEqual("screen", screen.Source);
Assert.IsNotEmpty(screen.Base64, "screen capture should return an image");
Assert.AreEqual(64, screen.Width);
// Pixel-presence needs the environment to actually present the overlay into the backbuffer.
// Some CI/windowless setups render a blank/stale backbuffer even in Play Mode; treat an
// unobservable overlay as inconclusive rather than a failure (verified live in the PR).
if (!overlayObserved)
{
UnityEngine.Object.Destroy(overlayGo);
UnityEngine.Object.Destroy(camGo);
// ExitPlayModeIfNeeded ([UnityTearDown]) restores edit mode after this ignore.
Assert.Ignore("Overlay not observable in this environment's game-view capture; screen path still verified.");
}
// Tone round-trip: the authored grays must read back near their values. The old AsTexture
// path double-encoded gamma in Linear projects (128 washed out to ~188). Orientation: the
// light half must be on top — CaptureScreenshotIntoRenderTexture returns GPU-UV-oriented
// content that the command must un-flip on D3D/Metal/Vulkan. (AUTHAPI-10 review)
Assert.That(stats.topLuma, Is.EqualTo((float)OverlayTopGray.r).Within(20f),
"top half should be the light gray at its authored tone (flip and/or gamma bug otherwise)");
Assert.That(stats.bottomLuma, Is.EqualTo((float)OverlayBottomGray.r).Within(20f),
"bottom half should be the dark gray at its authored tone (flip and/or gamma bug otherwise)");
// Overlay is observable here: prove it shows in a screen capture but NOT a camera capture
// (the camera clears to solid blue and renders nothing, so it contains no neutral gray).
var cameraShot = CaptureCommands.CaptureGameView(64, 64, camera: "CLI199_ScreenCam", source: "camera");
Assert.Less(GrayStats(cameraShot.Base64).fraction, 0.2f, "overlay UI must be absent from a camera capture");
UnityEngine.Object.Destroy(overlayGo);
UnityEngine.Object.Destroy(camGo);
yield return new ExitPlayMode();
}
/// <summary>
/// Decode the PNG and measure its neutral-gray content: the fraction of pixels that are
/// near-neutral (r≈g≈b, away from black/white clip), plus the mean luminance of the image's
/// top and bottom halves. Brightness-agnostic so gamma distortion shows up in the luminance
/// values (not as a hidden overlay), and per-half so a vertical flip swaps them detectably.
/// </summary>
private static (float fraction, float topLuma, float bottomLuma) GrayStats(string base64)
{
var tex = new Texture2D(2, 2);
try
{
Assert.IsTrue(tex.LoadImage(Convert.FromBase64String(base64)), "capture PNG should decode");
var width = tex.width;
var height = tex.height;
var pixels = tex.GetPixels32();
var gray = 0;
long topSum = 0, bottomSum = 0;
int topCount = 0, bottomCount = 0;
for (var y = 0; y < height; y++)
{
for (var x = 0; x < width; x++)
{
var p = pixels[y * width + x];
if (Mathf.Abs(p.r - p.g) <= 12 && Mathf.Abs(p.g - p.b) <= 12 && p.r >= 40 && p.r <= 240)
gray++;
// GetPixels32 row 0 is the image's BOTTOM row.
var luma = (p.r + p.g + p.b) / 3;
if (y >= height / 2) { topSum += luma; topCount++; }
else { bottomSum += luma; bottomCount++; }
}
}
return (pixels.Length == 0 ? 0f : (float)gray / pixels.Length,
topCount == 0 ? 0f : (float)topSum / topCount,
bottomCount == 0 ? 0f : (float)bottomSum / bottomCount);
}
finally
{
UnityEngine.Object.DestroyImmediate(tex);
}
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7469ce0ae01ac444280398a19366fdfc
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,243 @@
#if UNITY_6000_7_OR_NEWER
using System.IO;
using System.Linq;
using NUnit.Framework;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor;
using Unity.Pipeline.Editor.Commands;
using Unity.Pipeline.Runtime.Commands;
using UnityEditor;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.UIElements;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Tests for the visual-element capture commands. The selector resolver
/// (<see cref="VisualElementCaptureSupport.ResolveElement(VisualElement,string)"/>) is the
/// riskiest new logic and is GPU-independent, so it gets the most coverage; the
/// <c>capture_editor_element</c> command is covered for its validation/miss branches plus a
/// GPU-gated happy path.
/// </summary>
class CaptureVisualElementCommandsTests
{
// -- Selector resolver -------------------------------------------------------------------
static VisualElement BuildTree()
{
// root
// #toolbar .toolbar
// #search Button .primary (direct child of toolbar)
// #content
// #title Label .big (disabled)
var root = new VisualElement { name = "root" };
var toolbar = new VisualElement { name = "toolbar" };
toolbar.AddToClassList("toolbar");
var search = new Button { name = "search" };
search.AddToClassList("primary");
toolbar.Add(search);
var content = new VisualElement { name = "content" };
var title = new Label { name = "title" };
title.AddToClassList("big");
title.SetEnabled(false); // sets the :disabled pseudo-state
content.Add(title);
root.Add(toolbar);
root.Add(content);
return root;
}
[Test]
public void Resolve_ById()
{
var root = BuildTree();
var e = VisualElementCaptureSupport.ResolveElement(root, "#search");
Assert.IsNotNull(e);
Assert.AreEqual("search", e.name);
}
[Test]
public void Resolve_ByClass()
{
var root = BuildTree();
var e = VisualElementCaptureSupport.ResolveElement(root, ".toolbar");
Assert.IsNotNull(e);
Assert.AreEqual("toolbar", e.name);
}
[Test]
public void Resolve_ByType()
{
var root = BuildTree();
var e = VisualElementCaptureSupport.ResolveElement(root, "Label");
Assert.IsNotNull(e);
Assert.AreEqual("title", e.name);
Assert.IsInstanceOf<Label>(e);
}
[Test]
public void Resolve_TypeWithNameAndClass()
{
var root = BuildTree();
var e = VisualElementCaptureSupport.ResolveElement(root, "Button#search.primary");
Assert.IsNotNull(e);
Assert.AreEqual("search", e.name);
Assert.IsInstanceOf<Button>(e);
}
[Test]
public void Resolve_DescendantChain()
{
var root = BuildTree();
var e = VisualElementCaptureSupport.ResolveElement(root, ".toolbar #search");
Assert.IsNotNull(e);
Assert.AreEqual("search", e.name);
}
[Test]
public void Resolve_ChildCombinator()
{
var root = BuildTree();
// search IS a direct child of toolbar.
Assert.IsNotNull(VisualElementCaptureSupport.ResolveElement(root, "#toolbar > #search"));
// title is NOT a direct child of toolbar, so the child combinator must miss.
Assert.IsNull(VisualElementCaptureSupport.ResolveElement(root, "#toolbar > #title"));
}
[Test]
public void Resolve_PseudoDisabledAndEnabled()
{
var root = BuildTree();
Assert.IsNotNull(VisualElementCaptureSupport.ResolveElement(root, "#title:disabled"),
"title is disabled, so :disabled should match");
Assert.IsNull(VisualElementCaptureSupport.ResolveElement(root, "#title:enabled"),
"title is disabled, so :enabled should not match");
Assert.IsNull(VisualElementCaptureSupport.ResolveElement(root, "#title:not(disabled)"),
":not(disabled) should not match a disabled element");
}
[Test]
public void Resolve_Miss_ReturnsNull()
{
var root = BuildTree();
Assert.IsNull(VisualElementCaptureSupport.ResolveElement(root, "#does-not-exist"));
}
[Test]
public void Resolve_InvalidSelector_ReturnsNull()
{
var root = BuildTree();
Assert.IsNull(VisualElementCaptureSupport.ResolveElement(root, "#"), "dangling '#' is invalid");
Assert.IsNull(VisualElementCaptureSupport.ResolveElement(root, ""), "empty selector");
}
// -- capture_editor_element command ------------------------------------------------------
[Test]
public void EditorCommand_IsDiscovered()
{
CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
var cmd = CommandRegistry.DiscoverCommands().FirstOrDefault(c => c.Name == "capture_editor_element");
Assert.IsNotNull(cmd, "capture_editor_element should be discovered");
Assert.IsTrue(cmd.MainThreadRequired);
}
[Test]
public void RuntimeCommand_IsDiscovered_AndRuntimeOnly()
{
CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
var cmd = CommandRegistry.DiscoverCommands().FirstOrDefault(c => c.Name == "capture_runtime_element");
Assert.IsNotNull(cmd, "capture_runtime_element should be discovered");
Assert.IsTrue(cmd.RuntimeOnly, "runtime capture command should be RuntimeOnly");
}
[Test]
public void EditorCommand_MissingWindow_Fails()
{
var result = CaptureEditorElementCommand.CaptureEditorElement(window: "", selector: "#x");
Assert.IsFalse(result.Success);
StringAssert.Contains("window", result.Message);
}
[Test]
public void EditorCommand_UnknownWindow_Fails()
{
var result = CaptureEditorElementCommand.CaptureEditorElement(
window: "ThisWindowTypeDoesNotExist12345", selector: "#x");
Assert.IsFalse(result.Success);
StringAssert.Contains("No open EditorWindow", result.Message);
}
[Test]
public void EditorCommand_SelectorMiss_Fails()
{
var win = ScriptableObject.CreateInstance<TestCaptureWindow>();
try
{
win.Show();
var result = CaptureEditorElementCommand.CaptureEditorElement(
window: nameof(TestCaptureWindow), selector: "#no-such-element");
Assert.IsFalse(result.Success);
StringAssert.Contains("No element matched", result.Message);
}
finally
{
win.Close();
}
}
[Test]
public void EditorCommand_HappyPath_WritesPngAndBase64()
{
if (SystemInfo.graphicsDeviceType == GraphicsDeviceType.Null)
Assert.Ignore("No GPU available; capture readback is not meaningful in this environment.");
var win = ScriptableObject.CreateInstance<TestCaptureWindow>();
var output = Path.Combine(Path.GetTempPath(), $"pipeline_ve_{System.Guid.NewGuid():N}.png");
try
{
win.Show();
var result = CaptureEditorElementCommand.CaptureEditorElement(
window: nameof(TestCaptureWindow), selector: "#target", output: output);
Assert.IsTrue(result.Success, result.Message);
Assert.AreEqual("png", result.Encoding);
Assert.Greater(result.Width, 0);
Assert.Greater(result.Height, 0);
Assert.IsFalse(string.IsNullOrEmpty(result.Base64), "base64 payload should be populated");
Assert.Greater(result.Bytes, 0);
Assert.AreEqual(output, result.Path);
FileAssert.Exists(output);
}
finally
{
win.Close();
if (File.Exists(output))
File.Delete(output);
}
}
/// <summary>A minimal EditorWindow with one named, sized element to capture.</summary>
class TestCaptureWindow : EditorWindow
{
void CreateGUI()
{
var target = new VisualElement
{
name = "target",
style =
{
width = 120,
height = 60,
backgroundColor = Color.red
}
};
rootVisualElement.Add(target);
}
}
}
}
#endif
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 79d62a430827aa6449a37bbbb889e26c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,341 @@
using NUnit.Framework;
using System;
using System.IO;
using System.Text.RegularExpressions;
using Unity.Pipeline.Models;
using Unity.Pipeline.Runtime.Commands;
using Unity.Pipeline.Telemetry;
using Unity.Pipeline.Tests;
using UnityEngine;
using UnityEngine.TestTools;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Tests for the eval command (CodeEvalCommand), exercised directly and via PipelineClient.
/// Compiler-level behavior (EvalCodeCompiler) is covered by EvalCodeCompilerTests.
/// </summary>
class CodeEvalCommandTests
{
// Eval now writes eval-usage telemetry (AUTHAPI-29). Redirect it to a throwaway temp directory
// so these tests never append to the real project's Library/Pipeline/eval-usage.jsonl, and
// restore the mutated statics afterwards.
private string m_TelemetryDir;
private string m_SavedOverrideDir;
[SetUp]
public void SetUp()
{
m_TelemetryDir = Path.Combine(Path.GetTempPath(), "CodeEvalTelemetry_" + Guid.NewGuid().ToString("N")[..8]);
m_SavedOverrideDir = EvalUsageTelemetry.OverrideDirectory;
EvalUsageTelemetry.OverrideDirectory = m_TelemetryDir;
}
[TearDown]
public void TearDown()
{
// Recording is fire-and-forget; drain in-flight background records before the redirect
// target is deleted (a late append would otherwise recreate the temp directory).
EvalUsageTelemetry.WaitForPendingRecords();
EvalUsageTelemetry.OverrideDirectory = m_SavedOverrideDir;
if (Directory.Exists(m_TelemetryDir))
{
try { Directory.Delete(m_TelemetryDir, true); }
catch { /* ignore cleanup failures */ }
}
}
#region Direct
[Test]
public void EvaluateCode_SimpleArithmetic_ReturnsResult()
{
var r = CodeEvalCommand.EvaluateCode("return 2 + 2;");
Assert.IsTrue(r.Success, r.Error);
Assert.AreEqual(4, r.Result);
}
[Test]
public void EvaluateCode_StringExpression_ReturnsString()
{
var r = CodeEvalCommand.EvaluateCode("return \"Hello World\";");
Assert.IsTrue(r.Success, r.Error);
Assert.AreEqual("Hello World", r.Result);
}
[Test]
public void EvaluateCode_UnityApi_ReturnsVersion()
{
var r = CodeEvalCommand.EvaluateCode("return Application.unityVersion;");
Assert.IsTrue(r.Success, r.Error);
Assert.IsInstanceOf<string>(r.Result);
}
[Test]
public void EvaluateCode_DebugLog_ReturnsExplicitValue()
{
var r = CodeEvalCommand.EvaluateCode("Debug.Log(\"x\"); return \"logged\";");
Assert.IsTrue(r.Success, r.Error);
Assert.AreEqual("logged", r.Result);
}
[Test]
public void EvaluateCode_EmptyCode_BadRequest()
{
var r = CodeEvalCommand.EvaluateCode("");
Assert.IsFalse(r.Success);
Assert.AreEqual("Bad Request", r.Error);
}
[Test]
public void EvaluateCode_NullCode_BadRequest()
{
var r = CodeEvalCommand.EvaluateCode(null);
Assert.IsFalse(r.Success);
Assert.AreEqual("Bad Request", r.Error);
}
[Test]
public void EvaluateCode_SyntaxError_CompilationFailed()
{
var r = CodeEvalCommand.EvaluateCode("return 2 +;");
Assert.IsFalse(r.Success);
Assert.AreEqual("Compilation Failed", r.Error);
Assert.Greater(r.Diagnostics.Count, 0);
}
[Test]
public void EvaluateCode_RuntimeException_Fails()
{
var r = CodeEvalCommand.EvaluateCode("throw new System.Exception(\"boom\");");
Assert.IsFalse(r.Success);
Assert.IsNotNull(r.ErrorDetails);
}
[Test]
public void EvaluateCode_ZeroTimeout_BadRequest()
{
var r = CodeEvalCommand.EvaluateCode("return 1;", timeout: 0);
Assert.IsFalse(r.Success);
Assert.AreEqual("Bad Request", r.Error);
}
[Test]
public void EvaluateCode_ExcessiveTimeout_BadRequest()
{
// Cap raised to 24h (CLI-335): 40s is legal now; beyond the cap still rejects.
var r = CodeEvalCommand.EvaluateCode("return 1;", timeout: 86_400_001);
Assert.IsFalse(r.Success);
Assert.AreEqual("Bad Request", r.Error);
}
[Test]
public void EvaluateCode_LongRunningTimeoutRequest_ShouldNotBeRejectedOutright()
{
// Repro for the reported bug (a legitimately slow eval needed a timeout above 30s):
// EvaluateSource's hardcoded ceiling rejects any `timeout` above 30000ms outright,
// before even attempting compilation, so a caller can't even ask for more time
// regardless of whether their code would actually need it.
var r = CodeEvalCommand.EvaluateCode("return 1;", timeout: 35000);
Assert.IsTrue(r.Success, $"Expected a timeout request above 30000ms to be accepted, got: {r.Error} / {r.ErrorDetails}");
}
[Test]
public void EvaluateCode_RecordsExecutionTime()
{
var r = CodeEvalCommand.EvaluateCode("return 42;", timeout: 5000);
Assert.IsTrue(r.Success, r.Error);
Assert.Greater(r.ExecutionTimeMs, 0);
}
[Test]
public void EvaluateCode_Success_PopulatesEnvelopeMetadata()
{
var before = System.DateTime.UtcNow;
var r = CodeEvalCommand.EvaluateCode("return 42;");
Assert.IsTrue(r.Success, r.Error);
Assert.AreEqual("eval", r.Command);
Assert.AreNotEqual(default(System.DateTime), r.ExecutedAt, "executedAt must be a real timestamp");
Assert.GreaterOrEqual(r.ExecutedAt, before);
}
[Test]
public void EvaluateCode_Failure_PopulatesEnvelopeMetadata()
{
var before = System.DateTime.UtcNow;
var r = CodeEvalCommand.EvaluateCode("return 2 +;");
Assert.IsFalse(r.Success);
Assert.AreEqual("eval", r.Command);
Assert.AreNotEqual(default(System.DateTime), r.ExecutedAt, "executedAt must be a real timestamp");
Assert.GreaterOrEqual(r.ExecutedAt, before);
}
#endregion
#region EvalFile
[Test]
public void EvaluateFile_FromFile_ReturnsResult()
{
var path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
"eval_test_" + System.Guid.NewGuid().ToString("N") + ".cs");
System.IO.File.WriteAllText(path, "return 2 + 2;");
try
{
var r = CodeEvalCommand.EvaluateFile(path);
Assert.IsTrue(r.Success, r.Error);
Assert.AreEqual(4, r.Result);
}
finally
{
System.IO.File.Delete(path);
}
}
[Test]
public void EvaluateFile_FileNotFound_BadRequest()
{
var path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
"eval_missing_" + System.Guid.NewGuid().ToString("N") + ".cs");
var r = CodeEvalCommand.EvaluateFile(path);
Assert.IsFalse(r.Success);
Assert.AreEqual("Bad Request", r.Error);
}
[Test]
public void EvaluateFile_NonCsFile_BadRequest()
{
var path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
"eval_test_" + System.Guid.NewGuid().ToString("N") + ".txt");
System.IO.File.WriteAllText(path, "return 2 + 2;");
try
{
var r = CodeEvalCommand.EvaluateFile(path);
Assert.IsFalse(r.Success);
Assert.AreEqual("Bad Request", r.Error);
}
finally
{
System.IO.File.Delete(path);
}
}
[Test]
public void EvaluateFile_EmptyFile_BadRequest()
{
var path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
"eval_test_" + System.Guid.NewGuid().ToString("N") + ".cs");
System.IO.File.WriteAllText(path, " ");
try
{
var r = CodeEvalCommand.EvaluateFile(path);
Assert.IsFalse(r.Success);
Assert.AreEqual("Bad Request", r.Error);
}
finally
{
System.IO.File.Delete(path);
}
}
[Test]
public void EvaluateFile_NullFile_BadRequest()
{
var r = CodeEvalCommand.EvaluateFile(null);
Assert.IsFalse(r.Success);
Assert.AreEqual("Bad Request", r.Error);
}
#endregion
#region ViaClient
[Test]
public void Eval_ViaClient_ReturnsResult()
{
using (var server = new PipelineTestServer())
{
// The test client authenticates with the server's bearer token.
var response = server.Execute("eval", new { code = "return Application.platform.ToString();", timeout = 5000 });
Assert.IsTrue(response.IsSuccess, response.Error);
var r = response.GetTypedResponse<EvalResponse>();
Assert.IsNotNull(r, "Should deserialize an EvalResponse");
Assert.IsTrue(r.Success, r.Error);
Assert.IsInstanceOf<string>(r.Result);
}
}
[Test]
public void Eval_ViaClient_SyntaxError_CompilationFailed()
{
using (var server = new PipelineTestServer())
{
var response = server.Execute("eval", new { code = "return 2 +;", timeout = 5000 });
var r = response.GetTypedResponse<EvalResponse>();
Assert.IsNotNull(r, "Should deserialize an EvalResponse");
Assert.IsFalse(r.Success);
Assert.AreEqual("Compilation Failed", r.Error);
}
}
[Test]
public void Eval_ViaClient_RequestedTimeoutBelowDispatcherDefault_TimesOutEarly()
{
// Repro for the dispatcher half of the reported bug: the Dispatcher.Invoke wrapping every
// MainThreadRequired command (including eval) uses a hardcoded 60000ms wait, ignoring
// whatever `timeout` the caller actually asked for. So a caller who asks for far less than
// 60s currently just waits for the code to finish anyway; only the eval's own `timeout`
// value, once threaded through, should be the deadline that governs the wait.
using (var server = new PipelineTestServer())
{
LogAssert.Expect(LogType.Error, new Regex("Failed to handle /api/exec request.*timed out after 150ms"));
// PipelineTestServer.Execute's own pump loop dequeues and runs the eval work item
// synchronously once it starts, so it can't observe the request's completion until
// that (compile + 600ms sleep) finishes — give it enough headroom for that, on top of
// the compile itself, even though the actual dispatcher timeout below fires in ~150ms.
var response = server.Execute("eval",
new { code = "System.Threading.Thread.Sleep(600); return 1;", timeout = 150 },
timeoutMs: 20000);
Assert.IsFalse(response.IsSuccess,
$"Expected the 150ms request to time out instead of waiting for the 600ms sleep to finish: {response.RawResponse}");
}
}
[Test]
public void EvalFile_ViaClient_ReturnsResult()
{
var path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
"eval_test_" + System.Guid.NewGuid().ToString("N") + ".cs");
System.IO.File.WriteAllText(path, "return 6 * 7;");
try
{
using (var server = new PipelineTestServer())
{
var response = server.Execute("eval_file", new { file = path, timeout = 5000 });
Assert.IsTrue(response.IsSuccess, response.Error);
var r = response.GetTypedResponse<EvalResponse>();
Assert.IsNotNull(r, "Should deserialize an EvalResponse");
Assert.IsTrue(r.Success, r.Error);
Assert.AreEqual(42, r.Result);
}
}
finally
{
System.IO.File.Delete(path);
}
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0919730cfeec19e40b46ecce9479cf70
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,88 @@
using System.IO;
using NUnit.Framework;
using Unity.Pipeline.Compilation;
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Tests for the assemblyDir parameter of code reload cleanup.
/// Verifies that cleanup operates on the specified directory rather than the default temp directory.
/// </summary>
class CodeReloadAssemblyDirectoryTests
{
private string testAssemblyDir;
[SetUp]
public void SetUp()
{
testAssemblyDir = Path.Combine("TestAssemblies", "CodeReload");
Directory.CreateDirectory(testAssemblyDir);
// Clear state
CodeReloadRegistry.ClearAllForTesting();
}
[TearDown]
public void TearDown()
{
// Clean up test files and directories
if (Directory.Exists(testAssemblyDir))
{
Directory.Delete(testAssemblyDir, true);
}
if (Directory.Exists("TestAssemblies"))
{
Directory.Delete("TestAssemblies", true);
}
// Clean up code reload state
CodeReloadRegistry.ClearAllForTesting();
CodeReloadCompiler.CleanupCodeReloadDlls(); // Default cleanup
}
[Test]
public void CleanupCodeReloadDlls_WithAssemblyDir_CleansUpDiskFiles()
{
// Arrange - Create some test assembly files
var testAssembly1 = Path.Combine(testAssemblyDir, "TestAssembly_001.dll");
var testAssembly2 = Path.Combine(testAssemblyDir, "TestAssembly_002.dll");
var testAssembly3 = Path.Combine(testAssemblyDir, "TestAssembly_003.dll");
File.WriteAllText(testAssembly1, "fake dll content 1");
File.WriteAllText(testAssembly2, "fake dll content 2");
File.WriteAllText(testAssembly3, "fake dll content 3");
// Verify files exist before cleanup
Assert.IsTrue(File.Exists(testAssembly1), "Test file 1 should exist before cleanup");
Assert.IsTrue(File.Exists(testAssembly2), "Test file 2 should exist before cleanup");
Assert.IsTrue(File.Exists(testAssembly3), "Test file 3 should exist before cleanup");
// Act - Cleanup the specified directory
var result = CodeReloadCompiler.CleanupCodeReloadDlls(testAssemblyDir);
// Assert - Cleanup should succeed and keep only the latest version
Assert.IsTrue(result.Success, $"Cleanup should succeed. Message: {result.Message}");
Assert.AreEqual(2, result.DeletedFiles.Count, "Should delete the two older versions");
Assert.IsFalse(File.Exists(testAssembly1), "Version 001 should be deleted");
Assert.IsFalse(File.Exists(testAssembly2), "Version 002 should be deleted");
Assert.IsTrue(File.Exists(testAssembly3), "Latest version should be kept");
}
[Test]
public void CleanupCodeReloadDlls_WithMissingAssemblyDir_SucceedsWithoutDeleting()
{
// Arrange
var missingDir = Path.Combine(testAssemblyDir, "DoesNotExist");
Assert.IsFalse(Directory.Exists(missingDir), "Precondition: directory must not exist");
// Act
var result = CodeReloadCompiler.CleanupCodeReloadDlls(missingDir);
// Assert
Assert.IsTrue(result.Success, $"Cleanup should succeed for a missing directory. Message: {result.Message}");
Assert.AreEqual(0, result.DeletedFiles.Count, "Should not delete any files");
Assert.That(result.Message, Does.Contain("No code reload directory"));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7e24a4fd1f3d4c04e93baa986056cfbc
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,109 @@
using System.Linq;
using System.Reflection;
using NUnit.Framework;
using Unity.Pipeline.Compilation;
using Unity.Pipeline.CodeReload;
using UnityEngine;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Tests specifically for CodeReloadMethod attribute compilation and reflection.
/// Helps diagnose issues with attribute detection in compiled assemblies.
/// </summary>
class CodeReloadAttributeTests
{
[Test]
public void CodeReloadOverrideMethodAttribute_DirectUsage_CanBeReflected()
{
// Test that the attribute can be found via reflection on a directly compiled class
var method = typeof(TestCodeReloadClass).GetMethod("TestOverrideMethod");
Assert.IsNotNull(method, "Test method should exist");
var attribute = method.GetCustomAttribute<CodeReloadOverrideMethodAttribute>();
Assert.IsNotNull(attribute, "CodeReloadMethod attribute should be found");
Assert.AreEqual("TestTarget.TestMethod", attribute.TargetMethodId, "Target method ID should match");
}
[Test]
public void RoslynCompilationService_WithCodeReloadAttribute_CompilesAndReflects()
{
// Test that RoslynCompilationService can compile code with CodeReloadMethod attribute
// and the attribute can be reflected from the compiled assembly
var sourceCode = @"
using Unity.Pipeline.CodeReload;
using UnityEngine;
public static class CompiledCodeReloadClass
{
[CodeReloadOverrideMethod(""CompiledTarget.CompiledMethod"")]
public static void CompiledOverrideMethod(object instance)
{
Debug.Log(""Compiled code reload method"");
}
}";
var request = new CompilationRequest
{
SourceCode = sourceCode,
AssemblyName = "CodeReloadAttributeTest",
AdditionalAssemblyPrefixes = new[] { "Unity.Pipeline" }
};
var result = RoslynCompilationService.Compile(request);
Assert.IsTrue(result.Success, $"Compilation should succeed. Errors: {string.Join(", ", result.Diagnostics?.Select(d => d.Message) ?? new string[0])}");
Assert.IsNotNull(result.Assembly, "Should return compiled assembly");
// Find the compiled class and method
var compiledType = result.Assembly.GetType("CompiledCodeReloadClass");
Assert.IsNotNull(compiledType, "Compiled class should exist");
var compiledMethod = compiledType.GetMethod("CompiledOverrideMethod");
Assert.IsNotNull(compiledMethod, "Compiled method should exist");
// Check if attribute can be found via reflection
var customAttributes = compiledMethod.GetCustomAttributes(true);
var codeReloadAttribute = compiledMethod.GetCustomAttribute<CodeReloadOverrideMethodAttribute>();
if (codeReloadAttribute == null)
{
// Try to find by name
var attributeByName = customAttributes.FirstOrDefault(a => a.GetType().Name == "CodeReloadOverrideMethodAttribute");
if (attributeByName != null)
{
codeReloadAttribute = attributeByName as CodeReloadOverrideMethodAttribute;
}
}
Assert.IsNotNull(codeReloadAttribute, "CodeReloadMethod attribute should be found on compiled method");
Assert.AreEqual("CompiledTarget.CompiledMethod", codeReloadAttribute.TargetMethodId, "Target method ID should match");
}
[Test]
public void MetadataReferences_IncludeUnityPipeline_CanFindCodeReloadTypes()
{
// Verify that Unity.Pipeline assemblies are included in metadata references
var references = RoslynCompilationService.GetMetadataReferences(new[] { "Unity.Pipeline" });
var pipelineReferences = references.Where(r => r.Display?.Contains("Unity.Pipeline") == true).ToList();
Assert.Greater(pipelineReferences.Count, 0, "Should find Unity.Pipeline assembly references");
}
/// <summary>
/// Test class with CodeReloadMethod attribute for direct testing.
/// </summary>
public static class TestCodeReloadClass
{
[CodeReloadOverrideMethod("TestTarget.TestMethod")]
public static void TestOverrideMethod(object instance)
{
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1b46f0c74679e854288a8933221a317a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,316 @@
using System.Collections.Generic;
using System.IO;
using NUnit.Framework;
using Unity.Pipeline.CodeReload;
using Unity.Pipeline.Runtime.Commands;
using UnityEngine;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Probe for the baseline-aware in-place reload tests: two independent [CodeReload] methods so
/// a reload of one can be asserted to leave the other running compiled.
/// </summary>
public class BaselineReloadProbe
{
public int first;
public int second;
[CodeReload]
public void First()
{
first = 1; // original body
}
[CodeReload]
public void Second()
{
second = 1; // original body
}
}
/// <summary>
/// Tests for <see cref="CodeReloadBaseline"/> — the per-method snapshot that lets a reload skip
/// methods still matching the compiled code — and for its use by InPlaceReloadProcessor
/// (skip unchanged, unregister reverted).
/// </summary>
class CodeReloadBaselineTests
{
private const string FakePath = "Assets/FakeBaselineProbe.cs";
private const string BaselineSource = @"
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class BaselineReloadProbe
{
public int first;
public int second;
[CodeReload]
public void First()
{
first = 1; // original body
}
[CodeReload]
public void Second()
{
second = 1; // original body
}
}
}";
[TearDown]
public void TearDown()
{
CodeReloadBaseline.Clear();
CodeReloadRegistry.UnregisterMethodOverride("BaselineReloadProbe.First");
CodeReloadRegistry.UnregisterMethodOverride("BaselineReloadProbe.Second");
}
// -------- classification --------
[Test]
public void NoBaseline_ClassifiesNothing()
{
Assert.IsNull(CodeReloadBaseline.GetUnchangedMethods(FakePath, BaselineSource),
"Without a captured baseline every method must be treated as changed.");
Assert.IsFalse(CodeReloadBaseline.IsFileUpToDate(FakePath, BaselineSource));
}
[Test]
public void IdenticalSource_AllMethodsUnchanged()
{
Assert.IsTrue(CodeReloadBaseline.CaptureFromSource(FakePath, BaselineSource));
var unchanged = CodeReloadBaseline.GetUnchangedMethods(FakePath, BaselineSource);
Assert.IsNotNull(unchanged);
CollectionAssert.AreEquivalent(new[] { "First", "Second" }, unchanged);
Assert.IsTrue(CodeReloadBaseline.IsFileUpToDate(FakePath, BaselineSource));
}
[Test]
public void WhitespaceAndCommentEdits_DoNotCountAsChanges()
{
CodeReloadBaseline.CaptureFromSource(FakePath, BaselineSource);
var reformatted = BaselineSource
.Replace("first = 1; // original body", "first =\n 1; /* reflowed, comment changed */")
.Replace("public void Second()", "public void Second()");
var unchanged = CodeReloadBaseline.GetUnchangedMethods(FakePath, reformatted);
CollectionAssert.AreEquivalent(new[] { "First", "Second" }, unchanged);
Assert.IsTrue(CodeReloadBaseline.IsFileUpToDate(FakePath, reformatted));
}
[Test]
public void BodyEdit_MarksOnlyThatMethodChanged()
{
CodeReloadBaseline.CaptureFromSource(FakePath, BaselineSource);
var edited = BaselineSource.Replace("second = 1;", "second = 99;");
var unchanged = CodeReloadBaseline.GetUnchangedMethods(FakePath, edited);
CollectionAssert.AreEquivalent(new[] { "First" }, unchanged);
Assert.IsFalse(CodeReloadBaseline.IsFileUpToDate(FakePath, edited));
}
[Test]
public void SignatureEdit_MarksMethodChanged()
{
CodeReloadBaseline.CaptureFromSource(FakePath, BaselineSource);
// Renaming a parameterless method's parameter list is not possible; add a default arg.
var edited = BaselineSource.Replace("public void Second()", "public void Second(int times = 1)");
var unchanged = CodeReloadBaseline.GetUnchangedMethods(FakePath, edited);
// The signature change also changes the class context (the signature is context), so
// conservatively nothing is skippable.
CollectionAssert.DoesNotContain(unchanged, "Second");
}
[Test]
public void FieldEdit_ChangesContext_NothingSkippable()
{
CodeReloadBaseline.CaptureFromSource(FakePath, BaselineSource);
// An untouched body can read this field; the override compiles against the current
// file, so a declaration change conservatively invalidates every method.
var edited = BaselineSource.Replace("public int second;", "public int second = 5;");
var unchanged = CodeReloadBaseline.GetUnchangedMethods(FakePath, edited);
Assert.IsNotNull(unchanged);
Assert.AreEqual(0, unchanged.Count);
}
[Test]
public void HelperBodyEdit_DoesNotChangeContext()
{
var withHelper = BaselineSource.Replace(
"public int first;",
"public int first;\n private int Helper() { return 3; }\n private int Arrow => 4;");
CodeReloadBaseline.CaptureFromSource(FakePath, withHelper);
// Overrides call helpers through the compiled host either way, so a helper body edit
// can't change what an override does — it must not mark reloadable methods changed.
var edited = withHelper
.Replace("return 3;", "return 30;")
.Replace("Arrow => 4;", "Arrow => 40;");
var unchanged = CodeReloadBaseline.GetUnchangedMethods(FakePath, edited);
CollectionAssert.AreEquivalent(new[] { "First", "Second" }, unchanged);
}
[Test]
public void MustReloadMethods_BlockUpToDateShortCircuit()
{
CodeReloadBaseline.CaptureFromSource(FakePath, BaselineSource);
// A device already running an override for Second can't unregister it remotely — the
// file must not be reported up to date even though the source matches the baseline.
var pushed = new HashSet<string> { "Second" };
Assert.IsFalse(CodeReloadBaseline.IsFileUpToDate(FakePath, BaselineSource, null, pushed));
}
[Test]
public void SerializeRestore_RoundTripsClassification()
{
CodeReloadBaseline.CaptureFromSource(FakePath, BaselineSource);
var serialized = CodeReloadBaseline.Serialize();
CodeReloadBaseline.Clear();
Assert.IsNull(CodeReloadBaseline.GetUnchangedMethods(FakePath, BaselineSource));
CodeReloadBaseline.Restore(serialized);
var edited = BaselineSource.Replace("second = 1;", "second = 99;");
CollectionAssert.AreEquivalent(new[] { "First" },
CodeReloadBaseline.GetUnchangedMethods(FakePath, edited));
}
// -------- processor integration --------
[Test]
public void Reload_SkipsUnchangedMethods_AndUnregistersReverted()
{
CodeReloadRegistry.RegisterReloadableMethod(
typeof(BaselineReloadProbe).GetMethod(nameof(BaselineReloadProbe.First)),
new CodeReloadAttribute());
CodeReloadRegistry.RegisterReloadableMethod(
typeof(BaselineReloadProbe).GetMethod(nameof(BaselineReloadProbe.Second)),
new CodeReloadAttribute());
var path = Path.Combine(Application.temporaryCachePath, "BaselineReloadProbe_edit.cs");
try
{
// Watch start: disk matches the compiled probe.
CodeReloadBaseline.CaptureFromSource(path, BaselineSource);
// Save 1: untouched file → nothing compiles, nothing registers.
File.WriteAllText(path, BaselineSource);
var result = InPlaceReloadProcessor.ProcessSourceFileOnMainThread(path);
Assert.IsTrue(result.Success, result.ErrorMessage);
Assert.IsTrue(result.AllUpToDate);
CollectionAssert.IsEmpty(result.RegisteredMethods);
CollectionAssert.AreEquivalent(new[] { "First", "Second" }, result.UpToDateMethods);
Assert.IsFalse(CodeReloadRegistry.HasOverride("BaselineReloadProbe.Second"));
// Save 2: only Second edited → First stays compiled, Second gets an override.
File.WriteAllText(path, BaselineSource.Replace("second = 1;", "second = 99;"));
result = InPlaceReloadProcessor.ProcessSourceFileOnMainThread(path);
Assert.IsTrue(result.Success, result.ErrorMessage);
CollectionAssert.AreEquivalent(new[] { "BaselineReloadProbe.Second" }, result.RegisteredMethods);
CollectionAssert.AreEquivalent(new[] { "First" }, result.UpToDateMethods);
Assert.IsFalse(CodeReloadRegistry.HasOverride("BaselineReloadProbe.First"));
var probe = new BaselineReloadProbe();
probe.Second();
Assert.AreEqual(99, probe.second, "Edited body should run via the woven dispatch");
probe.First();
Assert.AreEqual(1, probe.first, "Unchanged body should run compiled");
// Save 3: the edit is undone → the stale override is removed, compiled body runs.
File.WriteAllText(path, BaselineSource);
result = InPlaceReloadProcessor.ProcessSourceFileOnMainThread(path);
Assert.IsTrue(result.Success, result.ErrorMessage);
Assert.IsTrue(result.AllUpToDate);
CollectionAssert.AreEquivalent(new[] { "BaselineReloadProbe.Second" }, result.RevertedMethods);
Assert.IsFalse(CodeReloadRegistry.HasOverride("BaselineReloadProbe.Second"));
var reverted = new BaselineReloadProbe();
reverted.Second();
Assert.AreEqual(1, reverted.second, "Reverted method should run the original compiled body");
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test] // The reload_file command's contract for the baseline paths: an up-to-date file is
// an explicit "up to date" success (never "successful with 0 methods"), and stale
// overrides it reverts are reported, not silently unregistered.
public void ReloadFileCommand_UpToDate_ReportsUpToDateAndReverted()
{
CodeReloadRegistry.RegisterReloadableMethod(
typeof(BaselineReloadProbe).GetMethod(nameof(BaselineReloadProbe.First)),
new CodeReloadAttribute());
CodeReloadRegistry.RegisterReloadableMethod(
typeof(BaselineReloadProbe).GetMethod(nameof(BaselineReloadProbe.Second)),
new CodeReloadAttribute());
var path = Path.Combine(Application.temporaryCachePath, "BaselineReloadProbe_cmd.cs");
try
{
CodeReloadBaseline.CaptureFromSource(path, BaselineSource);
// Edit Second → a normal reload that registers one override.
File.WriteAllText(path, BaselineSource.Replace("second = 1;", "second = 99;"));
var edited = CodeReloadCommands.ReloadFile(path);
Assert.IsTrue(edited.Success, edited.Message);
StringAssert.Contains("1 methods", edited.Message);
// Undo the edit → explicit up-to-date success that names the reverted override.
File.WriteAllText(path, BaselineSource);
var upToDate = CodeReloadCommands.ReloadFile(path);
Assert.IsTrue(upToDate.Success, upToDate.Message);
StringAssert.Contains("Up to date", upToDate.Message);
StringAssert.Contains("reverted 1 stale override(s): BaselineReloadProbe.Second", upToDate.Message);
StringAssert.DoesNotContain("0 methods", upToDate.Message);
Assert.IsFalse(CodeReloadRegistry.HasOverride("BaselineReloadProbe.Second"));
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void Reload_WithoutBaseline_ReloadsEverything()
{
CodeReloadRegistry.RegisterReloadableMethod(
typeof(BaselineReloadProbe).GetMethod(nameof(BaselineReloadProbe.First)),
new CodeReloadAttribute());
CodeReloadRegistry.RegisterReloadableMethod(
typeof(BaselineReloadProbe).GetMethod(nameof(BaselineReloadProbe.Second)),
new CodeReloadAttribute());
var path = Path.Combine(Application.temporaryCachePath, "BaselineReloadProbe_nobaseline.cs");
try
{
// No baseline captured (no watch): pre-baseline behavior — every method reloads.
File.WriteAllText(path, BaselineSource);
var result = InPlaceReloadProcessor.ProcessSourceFileOnMainThread(path);
Assert.IsTrue(result.Success, result.ErrorMessage);
Assert.IsFalse(result.AllUpToDate);
CollectionAssert.AreEquivalent(
new[] { "BaselineReloadProbe.First", "BaselineReloadProbe.Second" },
result.RegisteredMethods);
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4b97af406baa48a4955d8623a0ee0b57
@@ -0,0 +1,177 @@
using System.Collections;
using NUnit.Framework;
using Unity.Pipeline.CodeReload;
using UnityEngine;
using UnityEngine.TestTools;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Basic tests for code reload Pattern A method override functionality.
/// Tests the end-to-end flow: mark method -> create override -> compile -> verify behavior.
/// </summary>
class CodeReloadBasicTests
{
/// <summary>
/// Test class with reloadable methods for testing.
/// </summary>
public class TestComponent : MonoBehaviour
{
public int lastCalculatedValue;
public string lastMessage;
public void UpdateValue()
{
lastCalculatedValue = 10; // Original behavior
lastMessage = "Original UpdateValue called";
}
public int CalculateScore(int input)
{
return input * 2; // Original calculation
}
}
private TestComponent testComponent;
[SetUp]
public void SetUp()
{
// Create test component
var go = new GameObject("CodeReloadTestObject");
testComponent = go.AddComponent<TestComponent>();
// Clear any existing code reload state (including reloadable methods for test isolation)
CodeReloadRegistry.ClearAllForTesting();
}
[TearDown]
public void TearDown()
{
if (testComponent != null && testComponent.gameObject != null)
{
Object.DestroyImmediate(testComponent.gameObject);
}
// Clean up code reload state
CodeReloadRegistry.ClearAllForTesting();
}
[Test]
public void CodeReloadRegistry_RegisterReloadableMethod_Success()
{
// Arrange
var method = typeof(TestComponent).GetMethod("UpdateValue");
var attribute = new CodeReloadAttribute();
// Act
CodeReloadRegistry.RegisterReloadableMethod(method, attribute);
// Assert
var stats = CodeReloadRegistry.GetStats();
Assert.That(stats.ReloadableMethodCount, Is.EqualTo(1));
Assert.That(stats.ReloadableMethodIds.Contains("TestComponent.UpdateValue"));
}
[Test]
public void CodeReloadRegistry_RegisterReloadableMethodWithCustomId_Success()
{
// Arrange
var method = typeof(TestComponent).GetMethod("CalculateScore");
var attribute = new CodeReloadAttribute { Id = "CustomCalculation" };
// Act
CodeReloadRegistry.RegisterReloadableMethod(method, attribute);
// Assert
var stats = CodeReloadRegistry.GetStats();
Assert.That(stats.ReloadableMethodCount, Is.EqualTo(1));
Assert.That(stats.ReloadableMethodIds.Contains("CustomCalculation"));
}
[Test]
public void CodeReloadRegistry_TryInvokeCodeReload_NoOverrideReturnsFalse()
{
// Arrange
var method = typeof(TestComponent).GetMethod("UpdateValue");
var attribute = new CodeReloadAttribute();
CodeReloadRegistry.RegisterReloadableMethod(method, attribute);
// Act
var result = CodeReloadRegistry.TryInvokeCodeReload("TestComponent.UpdateValue", testComponent);
// Assert
Assert.That(result, Is.False, "Should return false when no code reload override is registered");
}
[Test]
public void CodeReloadRegistry_GetStats_ReturnsCorrectCounts()
{
// Arrange - register multiple methods
var updateMethod = typeof(TestComponent).GetMethod("UpdateValue");
var calculateMethod = typeof(TestComponent).GetMethod("CalculateScore");
CodeReloadRegistry.RegisterReloadableMethod(updateMethod, new CodeReloadAttribute());
CodeReloadRegistry.RegisterReloadableMethod(calculateMethod, new CodeReloadAttribute { Id = "CustomCalculation" });
// Act
var stats = CodeReloadRegistry.GetStats();
// Assert
Assert.That(stats.ReloadableMethodCount, Is.EqualTo(2));
Assert.That(stats.ActiveOverrideCount, Is.EqualTo(0));
Assert.That(stats.LoadedTypeCount, Is.EqualTo(0));
Assert.That(stats.ReloadableMethodIds.Count, Is.EqualTo(2));
}
[Test]
public void CodeReloadRegistry_ClearAllOverrides_ClearsAllState()
{
// Arrange - register some methods and mock some overrides
var method = typeof(TestComponent).GetMethod("UpdateValue");
CodeReloadRegistry.RegisterReloadableMethod(method, new CodeReloadAttribute());
// Act
CodeReloadRegistry.ClearAllOverrides();
// Assert
var stats = CodeReloadRegistry.GetStats();
Assert.That(stats.ActiveOverrideCount, Is.EqualTo(0));
Assert.That(stats.LoadedTypeCount, Is.EqualTo(0));
// Note: ReloadableMethodCount might not be cleared as those are the original methods, not overrides
// This depends on implementation - currently reloadable methods are not cleared, only overrides
}
[UnityTest]
public IEnumerator CodeReloadRegistry_MainThreadDispatch_WorksCorrectly()
{
// Arrange
var method = typeof(TestComponent).GetMethod("UpdateValue");
var attribute = new CodeReloadAttribute { RequireMainThread = true };
CodeReloadRegistry.RegisterReloadableMethod(method, attribute);
// Act - test that method can be invoked (even without override, for thread safety)
var result = CodeReloadRegistry.TryInvokeCodeReload("TestComponent.UpdateValue", testComponent);
// Assert
Assert.That(result, Is.False); // No override registered, should return false
// Yield to ensure any async operations complete
yield return null;
}
[Test]
public void TestComponent_OriginalBehavior_WorksCorrectly()
{
// Arrange & Act - call original methods to establish baseline
testComponent.UpdateValue();
var score = testComponent.CalculateScore(5);
// Assert
Assert.That(testComponent.lastCalculatedValue, Is.EqualTo(10));
Assert.That(testComponent.lastMessage, Is.EqualTo("Original UpdateValue called"));
Assert.That(score, Is.EqualTo(10)); // 5 * 2
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 96d537e6f81b9f140a08fabafd10500e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,106 @@
using NUnit.Framework;
using Unity.Pipeline.CodeReload;
using Unity.Pipeline.Runtime.Commands;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Integration tests for code reload CLI commands.
/// Tests the complete workflow: CLI commands → compiler → registry → runtime behavior.
/// </summary>
class CodeReloadCommandTests
{
[SetUp]
public void SetUp()
{
// Clear code reload state
CodeReloadRegistry.ClearAllForTesting();
}
[TearDown]
public void TearDown()
{
CodeReloadRegistry.ClearAllForTesting();
}
[Test]
public void CodeReloadStatus_ReturnsStats()
{
// Arrange - register a test method
var method = typeof(SimpleTestClass).GetMethod("TestMethod");
CodeReloadRegistry.RegisterReloadableMethod(method, new CodeReloadAttribute());
// Act
var result = CodeReloadCommands.CodeReloadStatus();
// Assert
Assert.That(result.Success, Is.True);
Assert.That(result.Message, Contains.Substring("Reloadable Methods: 1"));
Assert.That(result.Message, Contains.Substring("Active Overrides: 0"));
}
[Test]
public void ReloadFile_WithInvalidFilename_ReturnsBadRequest()
{
// Act
var result = CodeReloadCommands.ReloadFile("");
// Assert
Assert.That(result.Success, Is.False);
Assert.That(result.Error, Is.EqualTo("Bad Request"));
Assert.That(result.ErrorDetails, Contains.Substring("cannot be empty"));
}
[Test]
public void ReloadFile_WithNonExistentFile_ReturnsFileNotFound()
{
// Act
var result = CodeReloadCommands.ReloadFile("NonExistentFile.cs");
// Assert
Assert.That(result.Success, Is.False);
Assert.That(result.Error, Is.EqualTo("File Not Found"));
Assert.That(result.ErrorDetails, Contains.Substring("NonExistentFile.cs"));
}
[Test]
public void CodeReloadResponse_Success_CreatesCorrectResponse()
{
// Act
var response = CodeReloadResponse.CmdSuccess("test-assembly", "Test message", new System.Collections.Generic.List<string> { "method1", "method2" }, 1500);
// Assert
Assert.That(response.Success, Is.True);
Assert.That(response.AssemblyName, Is.EqualTo("test-assembly"));
Assert.That(response.Message, Is.EqualTo("Test message"));
Assert.That(response.Items.Count, Is.EqualTo(2));
Assert.That(response.ExecutionTimeMs, Is.EqualTo(1500));
}
[Test]
public void CodeReloadResponse_Failure_CreatesCorrectResponse()
{
// Act
var response = CodeReloadResponse.CmdFailure("Test Error", "Detailed error message", 2000);
// Assert
Assert.That(response.Success, Is.False);
Assert.That(response.Error, Is.EqualTo("Test Error"));
Assert.That(response.ErrorDetails, Is.EqualTo("Detailed error message"));
Assert.That(response.ExecutionTimeMs, Is.EqualTo(2000));
}
/// <summary>
/// Simple test class for code reload testing.
/// </summary>
public class SimpleTestClass
{
public int value;
public void TestMethod()
{
value = 42;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3f81555b71859b94c8349361be0d1a9b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,127 @@
using System.IO;
using System.Reflection;
using NUnit.Framework;
using Unity.Pipeline.Compilation;
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Tests for CodeReloadCompiler's cleanup of versioned code reload assemblies.
/// </summary>
class CodeReloadCompilerTests
{
private string testAssemblyDir;
[SetUp]
public void SetUp()
{
// Keep test files OUTSIDE Assets/ (project-root Temp/, which Unity ignores) so the
// editor never imports them — an import triggers a domain reload that tears down the
// live pipeline server.
testAssemblyDir = Path.GetFullPath(Path.Combine("Temp", "PipelineCodeReloadTests", "CompilerTests"));
Directory.CreateDirectory(testAssemblyDir);
// Clear code reload state
CodeReloadRegistry.ClearAllForTesting();
}
[TearDown]
public void TearDown()
{
// Clean up test files
if (Directory.Exists(testAssemblyDir))
{
Directory.Delete(testAssemblyDir, true);
}
// Clean up code reload state and temp files
CodeReloadRegistry.ClearAllForTesting();
CodeReloadCompiler.CleanupCodeReloadDlls();
}
[Test]
public void CleanupCodeReloadDlls_WithNoFiles_SucceedsGracefully()
{
// Act
var result = CodeReloadCompiler.CleanupCodeReloadDlls();
// 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 code reload directory").Or.Contain("0 old DLL versions"));
}
[Test]
public void CleanupCodeReloadDlls_WithVersionedDlls_KeepsOnlyLatestVersion()
{
// Arrange - three versions of one assembly, plus an unversioned DLL cleanup must leave alone
var v1 = Path.Combine(testAssemblyDir, "Sample_001.dll");
var v2 = Path.Combine(testAssemblyDir, "Sample_002.dll");
var v3 = Path.Combine(testAssemblyDir, "Sample_003.dll");
var unversioned = Path.Combine(testAssemblyDir, "Plain.dll");
File.WriteAllText(v1, "fake dll v1");
File.WriteAllText(v2, "fake dll v2");
File.WriteAllText(v3, "fake dll v3");
File.WriteAllText(unversioned, "fake dll");
// Act
var result = CodeReloadCompiler.CleanupCodeReloadDlls(testAssemblyDir);
// Assert
Assert.IsTrue(result.Success, $"Cleanup should succeed. Message: {result.Message}");
Assert.GreaterOrEqual(result.ExecutionTimeMs, 0, "Should record execution time (0ms is acceptable for fast cleanup)");
Assert.IsFalse(File.Exists(v1), "Version 001 should be deleted");
Assert.IsFalse(File.Exists(v2), "Version 002 should be deleted");
Assert.IsTrue(File.Exists(v3), "Latest version should be kept");
Assert.IsTrue(File.Exists(unversioned), "Unversioned DLLs should be left alone");
CollectionAssert.AreEquivalent(new[] { "Sample_001.dll", "Sample_002.dll" }, result.DeletedFiles);
}
[Test]
public void CleanupCodeReloadDlls_WithActiveOverrides_ClearsRegistry()
{
// Arrange - register a reloadable target and an override for it
var target = typeof(CleanupTarget).GetMethod(nameof(CleanupTarget.Tick));
CodeReloadRegistry.RegisterReloadableMethod(target, new CodeReloadAttribute { Id = "CleanupTarget.Tick" });
var overrideMethod = typeof(CleanupOverrides).GetMethod(nameof(CleanupOverrides.Tick));
var overrideAttribute = overrideMethod.GetCustomAttribute<CodeReloadOverrideMethodAttribute>();
Assert.IsTrue(CodeReloadRegistry.RegisterMethodOverride(overrideMethod, overrideAttribute, typeof(CleanupOverrides)),
"Override should register against the reloadable target");
Assert.Greater(CodeReloadRegistry.GetStats().ActiveOverrideCount, 0, "Precondition: an override is active");
// Act
var result = CodeReloadCompiler.CleanupCodeReloadDlls(testAssemblyDir);
// Assert
Assert.IsTrue(result.Success, $"Cleanup should succeed. Message: {result.Message}");
Assert.AreEqual(0, CodeReloadRegistry.GetStats().ActiveOverrideCount, "Registry should be cleared after cleanup");
}
/// <summary>
/// Target whose method the cleanup test registers as reloadable.
/// </summary>
public class CleanupTarget
{
public int value;
public void Tick()
{
value = 42;
}
}
/// <summary>
/// Override for <see cref="CleanupTarget.Tick"/>, in the shape the reload pipeline generates.
/// </summary>
public static class CleanupOverrides
{
[CodeReloadOverrideMethod("CleanupTarget.Tick")]
public static void Tick(CleanupTarget instance)
{
instance.value = 99;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 22dc4e614651b5b4e8d344bd9857fcf6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,55 @@
using NUnit.Framework;
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Round-trip tests for the editor→player code-reload push wire format
/// ({typeName, methodNames, il}).
/// </summary>
class CodeReloadConnectWireTests
{
static readonly byte[] k_Il = { 1, 2, 3, 4, 5 };
[Test]
public void Encode_RoundTrips()
{
var payload = PipelineCodeReloadConnect.Encode("MyType", new[] { "A", "B" }, k_Il);
Assert.IsTrue(PipelineCodeReloadConnect.TryDecode(
payload, out var typeName, out var methods, out var il));
Assert.AreEqual("MyType", typeName);
CollectionAssert.AreEqual(new[] { "A", "B" }, methods);
CollectionAssert.AreEqual(k_Il, il);
}
[Test]
public void Encode_EmptyIl_RoundTrips()
{
var payload = PipelineCodeReloadConnect.Encode("T", new[] { "A" }, null);
Assert.IsTrue(PipelineCodeReloadConnect.TryDecode(
payload, out var typeName, out var methods, out var il));
Assert.AreEqual("T", typeName);
CollectionAssert.AreEqual(new[] { "A" }, methods);
Assert.AreEqual(0, il.Length);
}
[Test]
public void TruncatedPayload_FailsCleanly()
{
var payload = PipelineCodeReloadConnect.Encode("MyType", new[] { "A" }, k_Il);
for (int cut = 1; cut < payload.Length; cut++)
{
var truncated = new byte[payload.Length - cut];
System.Array.Copy(payload, truncated, truncated.Length);
// Some truncations still parse as a shorter-but-valid IL section; the invariant
// that matters is no exception and no torn decode reporting success with the
// full IL length.
if (PipelineCodeReloadConnect.TryDecode(truncated, out _, out _, out var il))
Assert.Less(il.Length, k_Il.Length);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ac3dc81c245284dfc8e25b7be9dadf40
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,328 @@
using System.Collections;
using System.Collections.Generic;
using System.IO;
using NUnit.Framework;
using Unity.Pipeline.CodeReload;
using Unity.Pipeline.Runtime.Commands;
using UnityEngine;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Probe with a method-level [CodeReload] coroutine. The IL weaver routes IEnumerator
/// returns through TryInvokeCodeReloadResult (like any value-returning method), so a
/// registered override supplies the iterator that the caller — in Unity, StartCoroutine —
/// actually drives.
/// </summary>
public class CoroutineReloadProbe
{
public int originalRuns;
public int hotRuns;
public int touched;
[CodeReload]
public IEnumerator Run()
{
originalRuns++; // original body
yield return null;
}
[CodeReload]
public void Touch()
{
touched++; // original body
}
}
static class CoroutineOverrides
{
[CodeReloadOverrideMethod("CoroutineReloadProbe.Run")]
public static IEnumerator Run(CoroutineReloadProbe instance)
{
instance.hotRuns++;
yield return null;
}
}
/// <summary>
/// Probe for the INTERPRETER backend: the reloaded coroutine body runs as a IlInterpreter
/// state machine behind the ScriptEnumerator bridge (what an IL2CPP player does).
/// </summary>
public class IlInterpreterCoroutineProbe
{
public int originalSteps;
public int hotSteps;
[CodeReload]
public IEnumerator Run()
{
originalSteps++; // original body
yield return null;
}
}
/// <summary>
/// Coroutine code reload: `[CodeReload] IEnumerator` methods weave a result dispatch at
/// iterator CREATION, so each new StartCoroutine-style call picks up the active override
/// while in-flight iterators keep running the code they were created from.
/// </summary>
class CodeReloadCoroutineTests
{
[SetUp]
public void Setup() => CodeReloadRegistry.ClearAllForTesting();
[TearDown]
public void TearDown() => CodeReloadRegistry.ClearAllForTesting();
[Test]
public void Transform_CoroutineBody_EmitsIteratorOverride_KeepingYields()
{
const string coroutineSource = @"
using System.Collections;
using UnityEngine;
using Unity.Pipeline.CodeReload;
public class CoDemo : MonoBehaviour
{
public int step;
[CodeReload]
IEnumerator Run()
{
step = 1;
yield return null;
step = 2;
}
}";
// The transform lifts the body into a static method with the same IEnumerator
// return type; the yields survive, so the override compiles as a normal C# iterator.
var bodies = new Dictionary<string, string> { ["Run"] = "" };
var output = SourceCodeTransformer.TransformMethodBodies(
bodies, "CoDemo", new Dictionary<string, MethodSignatureInfo>(), coroutineSource);
StringAssert.Contains("[CodeReloadOverrideMethod(\"CoDemo.Run\")]", output);
StringAssert.Contains("public static IEnumerator Run(CoDemo instance", output);
StringAssert.Contains("yield return null", output);
StringAssert.Contains("instance.step", output);
}
[Test]
public void WovenDispatch_RoutesNewIteratorsToOverride()
{
CodeReloadRegistry.RegisterReloadableMethod(
typeof(CoroutineReloadProbe).GetMethod(nameof(CoroutineReloadProbe.Run)),
new CodeReloadAttribute());
var registered = CodeReloadRegistry.RegisterMethodOverride(
typeof(CoroutineOverrides).GetMethod(nameof(CoroutineOverrides.Run)),
new CodeReloadOverrideMethodAttribute("CoroutineReloadProbe.Run"),
typeof(CoroutineOverrides),
out var reason);
Assert.IsTrue(registered, reason);
// Run() was woven (IEnumerator returns go through TryInvokeCodeReloadResult), so the
// returned iterator IS the override's state machine.
var probe = new CoroutineReloadProbe();
var routine = probe.Run();
Assert.IsTrue(routine.MoveNext()); // iterators are lazy — body runs on first MoveNext
Assert.AreEqual(0, probe.originalRuns, "Original body must not run while an override is active");
Assert.AreEqual(1, probe.hotRuns, "Override iterator should have run");
}
[Test]
public void WovenDispatch_InFlightIteratorKeepsOldBody_NextCallGetsNew()
{
CodeReloadRegistry.RegisterReloadableMethod(
typeof(CoroutineReloadProbe).GetMethod(nameof(CoroutineReloadProbe.Run)),
new CodeReloadAttribute());
var probe = new CoroutineReloadProbe();
var inFlight = probe.Run(); // created BEFORE the override lands
CodeReloadRegistry.RegisterMethodOverride(
typeof(CoroutineOverrides).GetMethod(nameof(CoroutineOverrides.Run)),
new CodeReloadOverrideMethodAttribute("CoroutineReloadProbe.Run"),
typeof(CoroutineOverrides),
out _);
inFlight.MoveNext();
Assert.AreEqual(1, probe.originalRuns, "In-flight iterator keeps the body it was created from");
Assert.AreEqual(0, probe.hotRuns);
var next = probe.Run(); // created AFTER — dispatches to the override
next.MoveNext();
Assert.AreEqual(1, probe.originalRuns);
Assert.AreEqual(1, probe.hotRuns, "Next iterator creation should route to the override");
}
[Test]
public void ReloadFileInPlace_CoroutineOnlyFile_Succeeds()
{
CodeReloadRegistry.RegisterReloadableMethod(
typeof(CoroutineReloadProbe).GetMethod(nameof(CoroutineReloadProbe.Run)),
new CodeReloadAttribute());
var editedSource = @"
using System.Collections;
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class CoroutineReloadProbe
{
public int originalRuns;
public int hotRuns;
[CodeReload]
public IEnumerator Run()
{
hotRuns = 99;
yield return null;
}
}
}";
var path = Path.Combine(Application.temporaryCachePath, "CoroutineReloadProbe_edit.cs");
File.WriteAllText(path, editedSource);
try
{
var response = CodeReloadCommands.ReloadFile(path);
Assert.IsTrue(response.Success,
$"reload_file should succeed for a coroutine-only file. Error: {response.ErrorDetails}");
var probe = new CoroutineReloadProbe();
var routine = probe.Run();
routine.MoveNext(); // iterators are lazy — body only runs on first MoveNext
Assert.AreEqual(0, probe.originalRuns, "Original body must not run after reload");
Assert.AreEqual(99, probe.hotRuns, "Edited coroutine body should run via the woven dispatch");
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void ReloadFileInPlace_CoroutineNextToVoidMethod_ReloadsBoth()
{
CodeReloadRegistry.RegisterReloadableMethod(
typeof(CoroutineReloadProbe).GetMethod(nameof(CoroutineReloadProbe.Run)),
new CodeReloadAttribute());
CodeReloadRegistry.RegisterReloadableMethod(
typeof(CoroutineReloadProbe).GetMethod(nameof(CoroutineReloadProbe.Touch)),
new CodeReloadAttribute());
var editedSource = @"
using System.Collections;
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class CoroutineReloadProbe
{
public int originalRuns;
public int hotRuns;
public int touched;
[CodeReload]
public IEnumerator Run()
{
hotRuns = 99;
yield return null;
}
[CodeReload]
public void Touch()
{
touched = 42;
}
}
}";
var path = Path.Combine(Application.temporaryCachePath, "CoroutineReloadProbe_mixed_edit.cs");
File.WriteAllText(path, editedSource);
try
{
var result = InPlaceReloadProcessor.ProcessSourceFileOnMainThread(path);
Assert.IsTrue(result.Success, $"Mixed file should reload. Error: {result.ErrorMessage}");
CollectionAssert.Contains(result.ExtractedMethods, "Touch");
CollectionAssert.Contains(result.ExtractedMethods, "Run");
var probe = new CoroutineReloadProbe();
probe.Touch();
Assert.AreEqual(42, probe.touched, "Edited void body should run via the woven dispatch");
var routine = probe.Run();
routine.MoveNext();
Assert.AreEqual(0, probe.originalRuns);
Assert.AreEqual(99, probe.hotRuns, "Edited coroutine body should run via the woven dispatch");
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void ReloadFileInterpreter_Coroutine_RunsAsInterpretedStateMachine()
{
// The IL2CPP-shaped path: the edited coroutine compiles to a state machine the
// IlInterpreter VM interprets; the woven dispatch returns a ScriptEnumerator bridge
// that the caller (in Unity, StartCoroutine) drives like any IEnumerator.
CodeReloadRegistry.RegisterReloadableMethod(
typeof(IlInterpreterCoroutineProbe).GetMethod(nameof(IlInterpreterCoroutineProbe.Run)),
new CodeReloadAttribute());
var editedSource = @"
using System.Collections;
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class IlInterpreterCoroutineProbe
{
public int originalSteps;
public int hotSteps;
[CodeReload]
public IEnumerator Run()
{
hotSteps = 1;
yield return null;
hotSteps = 2;
yield return new UnityEngine.WaitForSeconds(0.25f);
hotSteps = 3;
}
}
}";
var path = Path.Combine(Application.temporaryCachePath, "IlInterpreterCoroutineProbe_edit.cs");
File.WriteAllText(path, editedSource);
try
{
var response = CodeReloadCommands.ReloadFileEditorInterpreter(path);
Assert.IsTrue(response.Success,
$"reload_file_editor_interpreter should succeed for a coroutine. Error: {response.ErrorDetails}");
var probe = new IlInterpreterCoroutineProbe();
var routine = probe.Run(); // woven dispatch → interpreter → bridge
Assert.IsTrue(routine.MoveNext());
Assert.IsNull(routine.Current, "yield return null must surface as a null step");
Assert.AreEqual(1, probe.hotSteps);
Assert.IsTrue(routine.MoveNext());
Assert.AreEqual(2, probe.hotSteps);
Assert.IsInstanceOf<WaitForSeconds>(routine.Current,
"a host object yielded from the interpreted body must cross the bridge intact");
Assert.IsFalse(routine.MoveNext());
Assert.AreEqual(3, probe.hotSteps);
Assert.AreEqual(0, probe.originalSteps, "original compiled body must not have run");
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 081d75116f3990a4398c6f8c474fa2f9
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e5830856fb74475c9b4284d67bb9c991
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,279 @@
using System.Xml;
using NUnit.Framework;
using Unity.Pipeline.Editor.BuildProcessors;
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Tests for the generated link.xml that preserves the interpreter's host-binding surface under
/// IL2CPP managed stripping. The build-time contribution runs only inside a real player build, but
/// the XML generator is pure and editor-callable, so its output is verified directly here.
/// </summary>
class CodeReloadLinkXmlGeneratorTests
{
[Test]
public void GenerateLinkXml_IsWellFormedLinkerDocument()
{
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml();
var doc = new XmlDocument();
Assert.DoesNotThrow(() => doc.LoadXml(xml), "Generated link.xml must be well-formed XML.");
Assert.AreEqual("linker", doc.DocumentElement.Name, "Root element must be <linker>.");
Assert.IsNotEmpty(doc.SelectNodes("/linker/assembly"), "Expected at least one <assembly> group.");
}
[Test]
public void GenerateLinkXml_PreservesUnityEngineColor_TheGreenYellowCase()
{
// Color.greenYellow exists in the Unity API but is stripped from an IL2CPP player when the
// built game never references it. AllowType<Color>() registers the whole type, so preserving
// Color with preserve="all" is exactly what keeps greenYellow (and every other member)
// callable from reloaded code.
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml();
var doc = new XmlDocument();
doc.LoadXml(xml);
var colorNode = doc.SelectSingleNode(
"/linker/assembly/type[@fullname='UnityEngine.Color']");
Assert.IsNotNull(colorNode, "UnityEngine.Color must be preserved so its members survive stripping.");
Assert.AreEqual("all", colorNode.Attributes["preserve"]?.Value,
"Color must be preserved with preserve=\"all\" to keep members like greenYellow.");
}
[Test]
public void GenerateLinkXml_GroupsColorUnderCoreModule()
{
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml();
var doc = new XmlDocument();
doc.LoadXml(xml);
var node = doc.SelectSingleNode(
"/linker/assembly[@fullname='UnityEngine.CoreModule']/type[@fullname='UnityEngine.Color']");
Assert.IsNotNull(node, "Color should be grouped under its owning assembly, UnityEngine.CoreModule.");
}
[Test]
public void GenerateLinkXml_WithPlayerAssemblies_PreservesUserAndEngineModulesWholesale()
{
// Demand-time auto-bind lets reloaded code call into any user or UnityEngine type, so the
// dev build must preserve those assemblies wholesale; BCL and Unity.* package assemblies
// stay per-type (the auto-bind policy declines the BCL, and Unity.* packages are AOT-heavy).
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml(
new[] { "Assembly-CSharp", "MyGame.Gameplay", "UnityEngine.CoreModule", "UnityEngine.AIModule",
"System.Core", "mscorlib", "Unity.Burst", "Unity.Pipeline" },
out _);
var doc = new XmlDocument();
doc.LoadXml(xml);
foreach (var expected in new[]
{ "Assembly-CSharp", "MyGame.Gameplay", "UnityEngine.CoreModule", "UnityEngine.AIModule" })
{
var node = doc.SelectSingleNode($"/linker/assembly[@fullname='{expected}']");
Assert.IsNotNull(node, $"{expected} must be preserved wholesale.");
Assert.AreEqual("all", node.Attributes["preserve"]?.Value,
$"{expected} must carry assembly-level preserve=\"all\".");
Assert.AreEqual(0, node.ChildNodes.Count,
$"{expected} is wholesale — per-type entries are subsumed.");
}
foreach (var excluded in new[] { "System.Core", "mscorlib", "Unity.Burst", "Unity.Pipeline" })
{
var node = doc.SelectSingleNode($"/linker/assembly[@fullname='{excluded}']");
Assert.IsTrue(node == null || node.Attributes["preserve"] == null,
$"{excluded} must not be preserved wholesale.");
}
}
[Test]
public void GenerateLinkXml_WithoutPlayerAssemblies_OnlyInterpreterEngineIsWholesale()
{
// Null player-assembly set (unreadable linker input, or the parameterless test entry
// point) degrades to per-type host-surface preservation plus the always-on interpreter
// engine assemblies — nothing else may be preserved wholesale.
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml();
var doc = new XmlDocument();
doc.LoadXml(xml);
foreach (XmlNode asm in doc.SelectNodes("/linker/assembly"))
{
var name = asm.Attributes["fullname"]?.Value;
if (name == "Unity.Pipeline.IlInterpreter" ||
name == "UnityPipeline.System.Reflection.Metadata" ||
name == "UnityPipeline.System.Collections.Immutable")
continue;
Assert.IsNull(asm.Attributes["preserve"],
$"without a player assembly set '{name}' may not be preserved wholesale");
}
}
[Test]
public void GenerateLinkXml_AlwaysPreservesInterpreterEngineAssembliesWholesale()
{
// The interpreter parses pushed override IL via System.Reflection.Metadata and resolves
// host members by name; under IL2CPP stripping those paths look unused. This preservation
// used to live in a static Runtime/link.xml, but that file applied to release builds too
// (real size cost for machinery that is compiled out of release players). The generator
// only contributes to development builds, so it now owns this set — with or without a
// player-assembly list.
foreach (var xml in new[]
{
CodeReloadLinkXmlGenerator.GenerateLinkXml(),
CodeReloadLinkXmlGenerator.GenerateLinkXml(new[] { "Assembly-CSharp" }, out _),
})
{
var doc = new XmlDocument();
doc.LoadXml(xml);
foreach (var expected in new[]
{ "Unity.Pipeline.IlInterpreter", "UnityPipeline.System.Reflection.Metadata", "UnityPipeline.System.Collections.Immutable" })
{
var node = doc.SelectSingleNode($"/linker/assembly[@fullname='{expected}']");
Assert.IsNotNull(node, $"{expected} must be preserved so the interpreter survives stripping.");
Assert.AreEqual("all", node.Attributes["preserve"]?.Value,
$"{expected} must carry assembly-level preserve=\"all\".");
}
}
}
[Test]
public void GenerateLinkXml_UnityPrefixedGameAssemblies_AreTreatedAsUserCode()
{
// A bare "Unity"/"System" prefix would misclassify game assemblies that merely start
// with the word — "UnityChanDemo", "SystemsRuntime" — as engine/BCL code, silently
// dropping them from wholesale preservation and from the [CodeReload] target scan
// (both share IsNonUserAssembly). Only real engine/BCL names are non-user.
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml(
new[] { "UnityChanDemo", "SystemsRuntime", "Unity", "Unity.Burst", "System.Core" },
out _);
var doc = new XmlDocument();
doc.LoadXml(xml);
foreach (var user in new[] { "UnityChanDemo", "SystemsRuntime" })
{
var node = doc.SelectSingleNode($"/linker/assembly[@fullname='{user}']");
Assert.IsNotNull(node, $"{user} is user code and must be preserved wholesale.");
Assert.AreEqual("all", node.Attributes["preserve"]?.Value,
$"{user} must carry assembly-level preserve=\"all\".");
}
foreach (var engine in new[] { "Unity", "Unity.Burst", "System.Core" })
{
var node = doc.SelectSingleNode($"/linker/assembly[@fullname='{engine}']");
Assert.IsTrue(node == null || node.Attributes["preserve"] == null,
$"{engine} is engine/BCL code and must not be preserved wholesale.");
}
}
// Rescue probe: this test assembly is Unity.Pipeline.Tests.Editor — it matches the
// engine-assembly name pattern, yet carries this [CodeReload] member, standing in for a
// game assembly named e.g. "Unity.MyGame".
class EngineNamedAssemblyProbe { [CodeReload] void Tick() { } }
[Test]
public void GenerateLinkXml_FindsCodeReloadTargets_InEngineNamedAssemblies()
{
// The [CodeReload] attribute must be authoritative for the target scan; the engine-name
// check is only a fast path. [CodeReload] lives in Unity.Pipeline, so an engine-named
// assembly that references Unity.Pipeline can carry targets and must still be scanned —
// otherwise its private state is stripped and reload fails on device only.
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml();
var expected = typeof(EngineNamedAssemblyProbe).FullName.Replace('+', '/');
StringAssert.Contains($"<type fullname=\"{expected}\" preserve=\"all\"/>", xml,
"[CodeReload] targets in an engine-named assembly must still be preserved.");
}
[Test]
public void GenerateLinkXml_WithPlayerAssemblies_DropsReloadTargetsNotStagedForTheBuild()
{
// The AppDomain scan sees editor-only and test assemblies that are not part of the
// player build; their [CodeReload] entries are noise in a game build's link.xml. When
// the staged set is known, only reload targets actually in the build are emitted —
// which also covers user test asmdefs. A test-player build stages its test
// assemblies, so fixtures keep their entries exactly where they matter.
var probe = typeof(EngineNamedAssemblyProbe).FullName.Replace('+', '/');
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml(new[] { "Assembly-CSharp" }, out _);
StringAssert.DoesNotContain(probe, xml,
"an unstaged assembly must not contribute reload-target entries");
}
[Test]
public void GenerateLinkXml_PromotesStagedEngineNamedAssembliesCarryingCodeReloadTargets()
{
// A staged, engine-named assembly carrying [CodeReload] members has identified itself
// as user code participating in code reload — a package-like name (e.g. "Unity.MyGame")
// must not demote it. Preserving only its [CodeReload] types would leave the rest of the
// assembly strippable, so demand-time auto-bind could hit stripped members on device
// only; instead it is preserved wholesale like any other user assembly. This test
// assembly (Unity.Pipeline.Tests.Editor, carrying the probe above) plays that role.
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml(
new[] { "Assembly-CSharp", "Unity.Pipeline.Tests.Editor" }, out _);
var doc = new XmlDocument();
doc.LoadXml(xml);
var node = doc.SelectSingleNode("/linker/assembly[@fullname='Unity.Pipeline.Tests.Editor']");
Assert.IsNotNull(node, "the promoted assembly must appear in the link.xml");
Assert.AreEqual("all", node.Attributes["preserve"]?.Value,
"the promoted assembly must carry assembly-level preserve=\"all\"");
Assert.AreEqual(0, node.ChildNodes.Count,
"wholesale preservation subsumes the per-type [CodeReload] entries");
}
[Test]
public void CollectPlayerAssemblyNames_WithEmptyInputDir_RecoversUnityEngineModulesFromTheEditor()
{
// The linker input directory is empty at IUnityLinkerProcessor time on some build backends
// (the symptom that stripped UITK ctors on device: a live-added `new Slider()` couldn't
// resolve because UnityEngine.UIElementsModule wasn't preserved wholesale). The collector
// must fall back to the editor's own assembly view so wholesale preservation still fires.
var names = CodeReloadLinkXmlGenerator.CollectPlayerAssemblyNames("/no/such/linker/input/dir");
Assert.IsNotNull(names, "an empty input dir must still yield the editor's player-assembly view");
CollectionAssert.Contains(names, "UnityEngine.UIElementsModule",
"the UnityEngine module carrying Slider must be recovered so its ctors survive stripping");
CollectionAssert.Contains(names, "UnityEngine.CoreModule",
"core UnityEngine modules must be recovered from the loaded editor assemblies");
}
[Test]
public void CollectPlayerAssemblyNames_FeedsWholesalePreservationOfUIElements()
{
// End-to-end for the Slider fix: the recovered assembly set, fed through the generator,
// must preserve UnityEngine.UIElementsModule wholesale (assembly-level preserve="all"),
// which is what keeps Slider's constructors callable from a reloaded body on device.
var names = CodeReloadLinkXmlGenerator.CollectPlayerAssemblyNames(null);
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml(names, out _);
var doc = new XmlDocument();
doc.LoadXml(xml);
var node = doc.SelectSingleNode("/linker/assembly[@fullname='UnityEngine.UIElementsModule']");
Assert.IsNotNull(node, "UnityEngine.UIElementsModule must be preserved so Slider's ctors survive.");
Assert.AreEqual("all", node.Attributes["preserve"]?.Value,
"UnityEngine.UIElementsModule must carry assembly-level preserve=\"all\".");
}
[Test]
public void GenerateLinkXml_EveryTypeIsPreservedAll()
{
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml();
var doc = new XmlDocument();
doc.LoadXml(xml);
foreach (XmlNode type in doc.SelectNodes("/linker/assembly/type"))
{
Assert.AreEqual("all", type.Attributes["preserve"]?.Value,
$"Type {type.Attributes["fullname"]?.Value} must use preserve=\"all\".");
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7819dc6a8789ae64f8dbe542ffae0c38
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,170 @@
using System.Reflection;
using NUnit.Framework;
using Unity.Pipeline.CodeReload;
using UnityEngine;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Tests for CodeReloadRegistry to verify method registration and statistics.
/// Helps diagnose issues with code reload method registration.
/// </summary>
class CodeReloadRegistryTests
{
[SetUp]
public void SetUp()
{
CodeReloadRegistry.ClearAllForTesting();
}
[TearDown]
public void TearDown()
{
CodeReloadRegistry.ClearAllForTesting();
}
[Test]
public void RegisterMethodOverride_DirectCall_UpdatesStats()
{
// Test direct registration to verify registry behavior
var method = typeof(TestRegistryClass).GetMethod("TestMethod");
var attribute = method.GetCustomAttribute<CodeReloadOverrideMethodAttribute>();
var type = typeof(TestRegistryClass);
Assert.IsNotNull(method, "Test method should exist");
Assert.IsNotNull(attribute, "Test attribute should exist");
// Get initial stats
var initialStats = CodeReloadRegistry.GetStats();
// CRITICAL: First register the target method as reloadable (this was missing!)
// Create a mock target method that matches the attribute's TargetMethodId
var targetMethod = typeof(MockTargetClass1).GetMethod("TargetMethod1");
var reloadableAttr = new CodeReloadAttribute { Id = "TargetClass1.TargetMethod1" };
CodeReloadRegistry.RegisterReloadableMethod(targetMethod, reloadableAttr);
// Register the type and method override
CodeReloadRegistry.RegisterCodeReloadType(type, "TestAssembly");
CodeReloadRegistry.RegisterMethodOverride(method, attribute, type);
// Get final stats
var finalStats = CodeReloadRegistry.GetStats();
// Verify stats changed
Assert.Greater(finalStats.ActiveOverrideCount, initialStats.ActiveOverrideCount, "Active override count should increase");
Assert.Greater(finalStats.LoadedTypeCount, initialStats.LoadedTypeCount, "Loaded type count should increase");
Assert.Greater(finalStats.ReloadableMethodCount, initialStats.ReloadableMethodCount, "Reloadable method count should increase");
}
[Test]
public void GetStats_AfterRegistration_ReturnsCorrectCounts()
{
// Test that GetStats returns accurate counts
var stats1 = CodeReloadRegistry.GetStats();
Assert.AreEqual(0, stats1.ActiveOverrideCount, "Should start with 0 active overrides");
// Register target methods as reloadable first (this was missing!)
var targetMethod1 = typeof(MockTargetClass1).GetMethod("TargetMethod1");
var reloadableAttr1 = new CodeReloadAttribute { Id = "TargetClass1.TargetMethod1" };
CodeReloadRegistry.RegisterReloadableMethod(targetMethod1, reloadableAttr1);
var targetMethod2 = typeof(MockTargetClass2).GetMethod("TargetMethod2");
var reloadableAttr2 = new CodeReloadAttribute { Id = "TargetClass2.TargetMethod2" };
CodeReloadRegistry.RegisterReloadableMethod(targetMethod2, reloadableAttr2);
// Register multiple methods
var method1 = typeof(TestRegistryClass).GetMethod("TestMethod");
var attribute1 = method1.GetCustomAttribute<CodeReloadOverrideMethodAttribute>();
CodeReloadRegistry.RegisterCodeReloadType(typeof(TestRegistryClass), "TestAssembly1");
CodeReloadRegistry.RegisterMethodOverride(method1, attribute1, typeof(TestRegistryClass));
var method2 = typeof(TestRegistryClass2).GetMethod("AnotherTestMethod");
var attribute2 = method2.GetCustomAttribute<CodeReloadOverrideMethodAttribute>();
CodeReloadRegistry.RegisterCodeReloadType(typeof(TestRegistryClass2), "TestAssembly2");
CodeReloadRegistry.RegisterMethodOverride(method2, attribute2, typeof(TestRegistryClass2));
var finalStats = CodeReloadRegistry.GetStats();
Assert.AreEqual(2, finalStats.ActiveOverrideCount, "Should have 2 active overrides");
Assert.AreEqual(2, finalStats.LoadedTypeCount, "Should have 2 loaded types");
Assert.AreEqual(2, finalStats.ReloadableMethodCount, "Should have 2 reloadable methods");
}
[Test]
public void ClearAllForTesting_ResetsStats()
{
// Register target method as reloadable first (this was missing!)
var targetMethod = typeof(MockTargetClass1).GetMethod("TargetMethod1");
var reloadableAttr = new CodeReloadAttribute { Id = "TargetClass1.TargetMethod1" };
CodeReloadRegistry.RegisterReloadableMethod(targetMethod, reloadableAttr);
// Register something first
var method = typeof(TestRegistryClass).GetMethod("TestMethod");
var attribute = method.GetCustomAttribute<CodeReloadOverrideMethodAttribute>();
CodeReloadRegistry.RegisterCodeReloadType(typeof(TestRegistryClass), "TestAssembly");
CodeReloadRegistry.RegisterMethodOverride(method, attribute, typeof(TestRegistryClass));
var statsBeforeClear = CodeReloadRegistry.GetStats();
Assert.Greater(statsBeforeClear.ActiveOverrideCount, 0, "Should have active overrides before clear");
Assert.Greater(statsBeforeClear.ReloadableMethodCount, 0, "Should have reloadable methods before clear");
// Clear and verify
CodeReloadRegistry.ClearAllForTesting();
var statsAfterClear = CodeReloadRegistry.GetStats();
Assert.AreEqual(0, statsAfterClear.ActiveOverrideCount, "Should have 0 active overrides after clear");
Assert.AreEqual(0, statsAfterClear.LoadedTypeCount, "Should have 0 loaded types after clear");
Assert.AreEqual(0, statsAfterClear.ReloadableMethodCount, "Should have 0 reloadable methods after clear");
}
/// <summary>
/// Test class with CodeReloadMethod attribute for registry testing.
/// </summary>
public static class TestRegistryClass
{
[CodeReloadOverrideMethod("TargetClass1.TargetMethod1")]
public static void TestMethod(MockTargetClass1 instance)
{
}
}
/// <summary>
/// Second test class for multi-registration testing.
/// </summary>
public static class TestRegistryClass2
{
[CodeReloadOverrideMethod("TargetClass2.TargetMethod2")]
public static void AnotherTestMethod(MockTargetClass2 instance)
{
}
}
/// <summary>
/// Mock target class 1 for testing reloadable method registration.
/// </summary>
public class MockTargetClass1
{
public int value = 42;
public void TargetMethod1()
{
value = 123;
}
}
/// <summary>
/// Mock target class 2 for testing reloadable method registration.
/// </summary>
public class MockTargetClass2
{
public string text = "original";
public void TargetMethod2()
{
text = "modified";
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 522caacf8c1467a44b3f4f93157f373a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,259 @@
using System.Collections;
using System.IO;
using NUnit.Framework;
using Unity.Pipeline.CodeReload;
using Unity.Pipeline.Runtime.Commands;
using UnityEngine;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Probe with method-level [CodeReload] STATIC methods. The weaver injects the same dispatch
/// prologue as for instance methods, passing null as the instance argument; the override
/// keeps the leading instance parameter and receives null.
/// </summary>
public class StaticReloadProbe
{
public static int baseline;
public static int hotValue;
public int instanceValue;
[CodeReload]
public static void Compute()
{
baseline++; // original body
}
[CodeReload]
public static void Mark(StaticReloadProbe target)
{
target.instanceValue = 1; // original body
}
[CodeReload]
public static IEnumerator Countdown(StaticReloadProbe target)
{
target.instanceValue = -1; // original body
yield return null;
}
}
static class StaticOverrides
{
[CodeReloadOverrideMethod("StaticReloadProbe.Compute")]
public static void Compute(StaticReloadProbe instance) // instance arrives null
{
StaticReloadProbe.hotValue++;
}
}
/// <summary>
/// Static method code reload: woven dispatch with a null instance, end-to-end reload_file on
/// the compiled (Assembly.Load) and interpreter (IlInterpreter) backends, and the static +
/// coroutine combination.
/// </summary>
class CodeReloadStaticTests
{
[SetUp]
public void Setup()
{
CodeReloadRegistry.ClearAllForTesting();
StaticReloadProbe.baseline = 0;
StaticReloadProbe.hotValue = 0;
}
[TearDown]
public void TearDown() => CodeReloadRegistry.ClearAllForTesting();
static void RegisterProbeMethod(string name) =>
CodeReloadRegistry.RegisterReloadableMethod(
typeof(StaticReloadProbe).GetMethod(name),
new CodeReloadAttribute());
[Test]
public void WovenDispatch_StaticMethod_RoutesToOverride_WithNullInstance()
{
RegisterProbeMethod(nameof(StaticReloadProbe.Compute));
var registered = CodeReloadRegistry.RegisterMethodOverride(
typeof(StaticOverrides).GetMethod(nameof(StaticOverrides.Compute)),
new CodeReloadOverrideMethodAttribute("StaticReloadProbe.Compute"),
typeof(StaticOverrides),
out var reason);
Assert.IsTrue(registered, reason);
StaticReloadProbe.Compute();
Assert.AreEqual(0, StaticReloadProbe.baseline, "Original body must not run while an override is active");
Assert.AreEqual(1, StaticReloadProbe.hotValue, "Override should have run (with a null instance)");
}
[Test]
public void ReloadFileCompiled_StaticMethod_AppliesEdit()
{
RegisterProbeMethod(nameof(StaticReloadProbe.Compute));
var editedSource = @"
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class StaticReloadProbe
{
public static int baseline;
public static int hotValue;
[CodeReload]
public static void Compute()
{
hotValue = 99;
}
}
}";
var path = Path.Combine(Application.temporaryCachePath, "StaticReloadProbe_edit.cs");
File.WriteAllText(path, editedSource);
try
{
var response = CodeReloadCommands.ReloadFile(path);
Assert.IsTrue(response.Success,
$"reload_file should succeed for a static method. Error: {response.ErrorDetails}");
StaticReloadProbe.Compute();
Assert.AreEqual(99, StaticReloadProbe.hotValue, "Edited static body should run via the woven dispatch");
Assert.AreEqual(0, StaticReloadProbe.baseline, "Original body must not run");
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void ReloadFileInterpreter_StaticMethod_WritesThroughParameter()
{
RegisterProbeMethod(nameof(StaticReloadProbe.Mark));
var editedSource = @"
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class StaticReloadProbe
{
public int instanceValue;
[CodeReload]
public static void Mark(StaticReloadProbe target)
{
target.instanceValue = 42;
}
}
}";
var path = Path.Combine(Application.temporaryCachePath, "StaticReloadProbe_mark_edit.cs");
File.WriteAllText(path, editedSource);
try
{
var response = CodeReloadCommands.ReloadFileEditorInterpreter(path);
Assert.IsTrue(response.Success,
$"reload_file_editor_interpreter should succeed for a static method. Error: {response.ErrorDetails}");
var probe = new StaticReloadProbe();
StaticReloadProbe.Mark(probe);
Assert.AreEqual(42, probe.instanceValue,
"Edited static body should run via the interpreter dispatch (null instance, real args)");
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void ReloadFileInterpreter_StaticMethod_WritesStaticField()
{
RegisterProbeMethod(nameof(StaticReloadProbe.Compute));
var editedSource = @"
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class StaticReloadProbe
{
public static int baseline;
public static int hotValue;
[CodeReload]
public static void Compute()
{
hotValue = 77;
}
}
}";
var path = Path.Combine(Application.temporaryCachePath, "StaticReloadProbe_sfield_edit.cs");
File.WriteAllText(path, editedSource);
try
{
var response = CodeReloadCommands.ReloadFileEditorInterpreter(path);
Assert.IsTrue(response.Success,
$"reload_file_editor_interpreter should succeed. Error: {response.ErrorDetails}");
StaticReloadProbe.Compute();
Assert.AreEqual(77, StaticReloadProbe.hotValue,
"Edited static body should write the host static field via the interpreter");
Assert.AreEqual(0, StaticReloadProbe.baseline, "Original body must not run");
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void ReloadFileInterpreter_StaticCoroutine_RunsAsInterpretedStateMachine()
{
RegisterProbeMethod(nameof(StaticReloadProbe.Countdown));
var editedSource = @"
using System.Collections;
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class StaticReloadProbe
{
public int instanceValue;
[CodeReload]
public static IEnumerator Countdown(StaticReloadProbe target)
{
target.instanceValue = 1;
yield return null;
target.instanceValue = 2;
}
}
}";
var path = Path.Combine(Application.temporaryCachePath, "StaticReloadProbe_coro_edit.cs");
File.WriteAllText(path, editedSource);
try
{
var response = CodeReloadCommands.ReloadFileEditorInterpreter(path);
Assert.IsTrue(response.Success,
$"reload_file_editor_interpreter should succeed for a static coroutine. Error: {response.ErrorDetails}");
var probe = new StaticReloadProbe();
var routine = StaticReloadProbe.Countdown(probe); // woven dispatch → interpreter → bridge
Assert.IsTrue(routine.MoveNext());
Assert.AreEqual(1, probe.instanceValue);
Assert.IsFalse(routine.MoveNext());
Assert.AreEqual(2, probe.instanceValue,
"Static coroutine edit should run as an interpreted state machine");
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 972068c2e04442ccb2b86322d054f0cf
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,136 @@
using System;
using System.Reflection;
using NUnit.Framework;
using Unity.Pipeline.CodeReload;
using UnityEngine;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Simple verification tests for code reload infrastructure.
/// These tests verify the core functionality works correctly in isolation.
/// </summary>
class CodeReloadVerificationTests
{
[SetUp]
public void SetUp()
{
// Ensure clean state for each test
CodeReloadRegistry.ClearAllForTesting();
}
[TearDown]
public void TearDown()
{
// Clean up after each test
CodeReloadRegistry.ClearAllForTesting();
}
[Test]
public void CodeReloadRegistry_EmptyState_HasZeroStats()
{
// Act
var stats = CodeReloadRegistry.GetStats();
// Assert
Assert.That(stats.ReloadableMethodCount, Is.EqualTo(0));
Assert.That(stats.ActiveOverrideCount, Is.EqualTo(0));
Assert.That(stats.LoadedTypeCount, Is.EqualTo(0));
}
[Test]
public void CodeReloadRegistry_RegisterSingleMethod_CorrectCount()
{
// Arrange
var testType = typeof(SimpleTestClass);
var testMethod = testType.GetMethod("TestMethod");
var attribute = new CodeReloadAttribute();
// Act
CodeReloadRegistry.RegisterReloadableMethod(testMethod, attribute);
// Assert
var stats = CodeReloadRegistry.GetStats();
Assert.That(stats.ReloadableMethodCount, Is.EqualTo(1));
Assert.That(stats.ReloadableMethodIds.Count, Is.EqualTo(1));
Assert.That(stats.ReloadableMethodIds.Contains("SimpleTestClass.TestMethod"));
}
[Test]
public void CodeReloadRegistry_RegisterMultipleMethods_CorrectCount()
{
// Arrange
var testType = typeof(SimpleTestClass);
var method1 = testType.GetMethod("TestMethod");
var method2 = testType.GetMethod("AnotherTestMethod");
// Act
CodeReloadRegistry.RegisterReloadableMethod(method1, new CodeReloadAttribute());
CodeReloadRegistry.RegisterReloadableMethod(method2, new CodeReloadAttribute { Id = "CustomId" });
// Assert
var stats = CodeReloadRegistry.GetStats();
Assert.That(stats.ReloadableMethodCount, Is.EqualTo(2));
Assert.That(stats.ReloadableMethodIds.Count, Is.EqualTo(2));
Assert.That(stats.ReloadableMethodIds.Contains("SimpleTestClass.TestMethod"));
Assert.That(stats.ReloadableMethodIds.Contains("CustomId"));
}
[Test]
public void CodeReloadRegistry_TryInvokeWithoutOverride_ReturnsFalse()
{
// Arrange
var testType = typeof(SimpleTestClass);
var testMethod = testType.GetMethod("TestMethod");
CodeReloadRegistry.RegisterReloadableMethod(testMethod, new CodeReloadAttribute());
var testInstance = new SimpleTestClass();
// Act
var result = CodeReloadRegistry.TryInvokeCodeReload("SimpleTestClass.TestMethod", testInstance);
// Assert
Assert.That(result, Is.False);
}
[Test]
public void CodeReloadRegistry_ClearForTesting_RemovesAllState()
{
// Arrange - add some state
var testType = typeof(SimpleTestClass);
var testMethod = testType.GetMethod("TestMethod");
CodeReloadRegistry.RegisterReloadableMethod(testMethod, new CodeReloadAttribute());
// Verify state exists
var statsBefore = CodeReloadRegistry.GetStats();
Assert.That(statsBefore.ReloadableMethodCount, Is.EqualTo(1));
// Act
CodeReloadRegistry.ClearAllForTesting();
// Assert
var statsAfter = CodeReloadRegistry.GetStats();
Assert.That(statsAfter.ReloadableMethodCount, Is.EqualTo(0));
Assert.That(statsAfter.ActiveOverrideCount, Is.EqualTo(0));
Assert.That(statsAfter.LoadedTypeCount, Is.EqualTo(0));
}
/// <summary>
/// Simple test class for code reload testing.
/// </summary>
public class SimpleTestClass
{
public int value;
public void TestMethod()
{
value = 42;
}
public int AnotherTestMethod(int input)
{
return input * 2;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d32ede884c994bb4b9f1b1bf6053697a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,732 @@
using System.Collections.Generic;
using System.Reflection;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using Unity.Pipeline;
using Unity.Pipeline.Commands;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Unit tests for CommandLineBinder - binds argv tokens to a command's declared
/// parameters, accumulating every defect rather than bailing on the first.
///
/// The load-bearing invariant is CONVERGENCE: the JObject produced from a command line
/// must be indistinguishable from the structured <c>parameters</c> object a client would
/// have sent, so everything from ExtractCommandParameters downwards has exactly one code
/// path. That is what
/// <see cref="TryBind_PositionalAndFlagAndStructured_AllProduceTheSameObject"/> pins.
/// </summary>
class CommandLineBinderTests
{
// The binder reads only Name and Parameters, but CommandInfo requires a non-null
// MethodInfo. Any method will do; this one exists solely to satisfy that.
private static void Placeholder() { }
private static readonly MethodInfo s_Placeholder =
typeof(CommandLineBinderTests).GetMethod(nameof(Placeholder),
BindingFlags.NonPublic | BindingFlags.Static);
private static CommandParameterInfo Param(string name, System.Type type, bool required = false,
object defaultValue = null) =>
new CommandParameterInfo(name, $"{name} description", required, type, defaultValue);
private static CommandInfo Command(string name, params CommandParameterInfo[] parameters) =>
new CommandInfo(name, $"{name} description", false, s_Placeholder, parameters);
/// <summary>log_editor: one required string, the simplest real shape.</summary>
private static CommandInfo LogEditor() =>
Command("log_editor", Param("message", typeof(string), required: true));
private static JObject Bind(CommandInfo command, params string[] args)
{
Assert.IsTrue(CommandLineBinder.TryBind(command, args, out var parameters, out var problems),
$"Expected a clean bind, got problems: {Describe(problems)}");
Assert.IsEmpty(problems, "A successful bind must report no problems");
return parameters;
}
private static List<ArgProblem> BindProblems(CommandInfo command, params string[] args)
{
Assert.IsFalse(CommandLineBinder.TryBind(command, args, out _, out var problems),
"Expected the bind to be rejected");
Assert.IsNotEmpty(problems, "A failed bind must report at least one problem");
return problems;
}
private static string Describe(List<ArgProblem> problems)
{
if (problems == null) return "<null>";
var parts = new List<string>();
foreach (var p in problems)
parts.Add($"{p.Kind}(name={p.Name}, token={p.Token}, suggestion={p.Suggestion})");
return string.Join("; ", parts);
}
// ---------------------------------------------------------------- convergence
[Test]
public void TryBind_PositionalAndFlagAndStructured_AllProduceTheSameObject()
{
var expected = new JObject { ["message"] = "hi" };
Assert.IsTrue(JToken.DeepEquals(expected, Bind(LogEditor(), "hi")),
"positional form must match the structured equivalent");
Assert.IsTrue(JToken.DeepEquals(expected, Bind(LogEditor(), "--message", "hi")),
"--key value form must match the structured equivalent");
Assert.IsTrue(JToken.DeepEquals(expected, Bind(LogEditor(), "--message=hi")),
"--key=value form must match the structured equivalent");
}
[Test]
public void TryBind_StoresStringValues_SoTheExecutorSeesOneShape()
{
// Values are stored as string JValues, never pre-converted. ConvertParameterToken
// does the widening downstream, identically for both request forms.
var command = Command("test_types", Param("count", typeof(int), defaultValue: 1));
var bound = Bind(command, "--count", "5");
Assert.AreEqual(JTokenType.String, bound["count"].Type,
"the binder must not pre-convert; coercion happens in the executor");
Assert.AreEqual("5", bound["count"].Value<string>());
}
[Test]
public void TryBind_NoArguments_ProducesEmptyObject()
{
var bound = Bind(Command("test_tagged"));
Assert.AreEqual(0, bound.Count);
}
// ---------------------------------------------------------------- flag classification
[Test]
public void TryBind_EmptyStringValue_IsTheEmptyStringNotTrue()
{
// Conflating "no next token" with "empty next token" made `--message ""`
// send boolean true. Absence and emptiness are different.
var bound = Bind(LogEditor(), "--message", "");
Assert.AreEqual(JTokenType.String, bound["message"].Type);
Assert.AreEqual(string.Empty, bound["message"].Value<string>());
}
[Test]
public void TryBind_FlagFollowedBySingleDashToken_ConsumesItAsTheValue()
{
// `--message -foo` is the escape hatch for values that look like flags.
// The follower is consumed unless it starts with "--".
var bound = Bind(LogEditor(), "--message", "-foo");
Assert.AreEqual("-foo", bound["message"].Value<string>());
}
[Test]
public void TryBind_DuplicateFlag_ReportsDuplicate()
{
var problems = BindProblems(LogEditor(), "--message", "a", "--message", "b");
Assert.AreEqual(1, problems.Count);
Assert.AreEqual(ArgProblemKind.Duplicate, problems[0].Kind);
Assert.AreEqual("message", problems[0].Name);
}
[Test]
public void TryBind_FlagWithEmptyName_ReportsEmptyName()
{
var problems = BindProblems(LogEditor(), "--=oops");
Assert.AreEqual(ArgProblemKind.EmptyName, problems[0].Kind);
Assert.AreEqual("--=oops", problems[0].Token);
}
[Test]
public void TryBind_FlagWithTrailingEquals_ReportsEmptyValue()
{
var problems = BindProblems(LogEditor(), "--message=");
Assert.AreEqual(ArgProblemKind.EmptyValue, problems[0].Kind);
Assert.AreEqual("message", problems[0].Name);
}
[Test]
public void TryBind_UnknownFlag_ReportsUnknownNameWithDidYouMean()
{
var problems = BindProblems(LogEditor(), "--mesage", "hi");
Assert.AreEqual(1, problems.Count);
Assert.AreEqual(ArgProblemKind.UnknownName, problems[0].Kind);
Assert.AreEqual("mesage", problems[0].Name);
Assert.AreEqual("message", problems[0].Suggestion, "a one-character typo must suggest");
}
[Test]
public void TryBind_UnknownFlagUnlikeAnyParameter_ReportsNoSuggestion()
{
var problems = BindProblems(LogEditor(), "--zzzzzzzzz", "hi");
Assert.AreEqual(ArgProblemKind.UnknownName, problems[0].Kind);
Assert.IsNull(problems[0].Suggestion, "an unrelated name must not get a spurious suggestion");
}
[Test]
public void TryBind_AccumulatesEveryProblem_RatherThanBailingOnTheFirst()
{
var problems = BindProblems(LogEditor(), "--nope", "x", "--=bad");
Assert.AreEqual(2, problems.Count, $"expected both problems, got: {Describe(problems)}");
}
// ---------------------------------------------------------------- positional slots
[Test]
public void TryBind_Positionals_FillRequiredThenOptionalInDeclarationOrder()
{
// Declaration order is: optional 'first', required 'second'. Required wins the
// first positional slot regardless of where it is declared.
var command = Command("two_slots",
Param("first", typeof(string)),
Param("second", typeof(string), required: true));
var bound = Bind(command, "A", "B");
Assert.AreEqual("A", bound["second"].Value<string>(), "required slot fills first");
Assert.AreEqual("B", bound["first"].Value<string>());
}
/// <summary>
/// The <c>find_assets</c> shape: every parameter optional, so there is no required slot
/// to anchor on. The first positional must still land on the FIRST DECLARED parameter.
///
/// A client that binds against a fetched catalog has a failure mode here that this
/// design does not: lose the schema and there is nothing left to order by, so a
/// positional has to be guessed at. Binding against the reflected signature cannot
/// degrade that way — the declaration is always present.
/// </summary>
[Test]
public void TryBind_AllOptionalParameters_StillFillInDeclarationOrder()
{
var findAssets = Command("find_assets",
Param("type", typeof(string)),
Param("name", typeof(string)),
Param("label", typeof(string)),
Param("search_in", typeof(string)),
Param("limit", typeof(int), defaultValue: 200));
var one = Bind(findAssets, "Texture2D");
Assert.IsTrue(JToken.DeepEquals(new JObject { ["type"] = "Texture2D" }, one),
"a single positional binds to the first declared parameter and nothing else");
var two = Bind(findAssets, "Texture2D", "Grass");
Assert.AreEqual("Texture2D", two["type"].Value<string>());
Assert.AreEqual("Grass", two["name"].Value<string>());
Assert.IsNull(two["label"], "unfilled optional slots stay absent, not null");
}
[Test]
public void TryBind_AllOptionalParameters_FlagAndPositionalCombine()
{
// `find_assets Texture2D --limit 5`: the flag takes its own slot, and the positional
// still lands on `type` rather than being pushed along by it.
var findAssets = Command("find_assets",
Param("type", typeof(string)),
Param("name", typeof(string)),
Param("limit", typeof(int), defaultValue: 200));
var bound = Bind(findAssets, "Texture2D", "--limit", "5");
Assert.AreEqual("Texture2D", bound["type"].Value<string>());
Assert.AreEqual("5", bound["limit"].Value<string>());
Assert.IsNull(bound["name"]);
}
[Test]
public void TryBind_SlotAlreadyFilledByFlag_IsSkippedNotOverwritten()
{
var command = Command("two_slots",
Param("a", typeof(string), required: true),
Param("b", typeof(string)));
var bound = Bind(command, "--a", "viaFlag", "viaPositional");
Assert.AreEqual("viaFlag", bound["a"].Value<string>(), "the flag must win its own slot");
Assert.AreEqual("viaPositional", bound["b"].Value<string>(), "the positional moves to the next free slot");
}
[Test]
public void TryBind_MorePositionalsThanSlots_ReportsExcessWithCapacityAndGiven()
{
var problems = BindProblems(LogEditor(), "one", "two", "three");
Assert.AreEqual(ArgProblemKind.ExcessPositional, problems[0].Kind);
Assert.AreEqual(1, problems[0].Capacity, "log_editor declares one slot");
Assert.AreEqual(3, problems[0].Given);
}
[Test]
public void TryBind_CommandWithNoParameters_RejectsAnyPositional()
{
var problems = BindProblems(Command("test_tagged"), "stray");
Assert.AreEqual(ArgProblemKind.ExcessPositional, problems[0].Kind);
Assert.AreEqual(0, problems[0].Capacity);
}
[Test]
public void TryBind_SingleDashInPositionalPosition_IsReported()
{
var problems = BindProblems(LogEditor(), "-foo");
Assert.AreEqual(ArgProblemKind.SingleDash, problems[0].Kind);
Assert.AreEqual("-foo", problems[0].Token);
}
[Test]
public void TryBind_NegativeNumberInPositionalPosition_IsNotASingleDashProblem()
{
// -5 and -1.5 are values, not malformed flags.
var command = Command("takes_number", Param("value", typeof(string), required: true));
Assert.AreEqual("-5", Bind(command, "-5")["value"].Value<string>());
Assert.AreEqual("-1.5", Bind(command, "-1.5")["value"].Value<string>());
Assert.AreEqual("-", Bind(command, "-")["value"].Value<string>());
}
[Test]
public void TryBind_SingleDashRule_IsLetterAnchoredExactlyLikeTheCli()
{
// The CLI keeps this check client-side (offline, no Editor boot), which is only safe
// while its rule is a strict SUBSET of ours. Its rule is char.IsAsciiLetter(token[1]),
// so '-5x' must be a VALUE here too -- a looser "not a number" test would reject it
// server-side after the CLI had already waved it through.
var command = Command("takes_value", Param("value", typeof(string), required: true));
Assert.AreEqual("-5x", Bind(command, "-5x")["value"].Value<string>());
Assert.AreEqual(ArgProblemKind.SingleDash, BindProblems(command, "-foo")[0].Kind);
}
[Test]
public void TryBind_BareAssignmentNamingAParameter_IsReportedButStillCountsAsPositional()
{
// Rejects `create_folder path=Assets/Foo` (a misremembered flag syntax) while
// leaving `eval "obj=null;"` alone -- see the eval test below.
var command = Command("create_folder", Param("path", typeof(string), required: true));
var problems = BindProblems(command, "path=Assets/Foo");
Assert.AreEqual(ArgProblemKind.BareAssignment, problems[0].Kind);
Assert.AreEqual("path=Assets/Foo", problems[0].Token);
}
[Test]
public void TryBind_EvalCodeContainingAnAssignment_BindsCleanly()
{
// THE case the bare-assignment heuristic must not break: 'obj' is not a
// declared parameter of eval, so this is code, not a misspelled flag.
var eval = Command("eval",
Param("code", typeof(string), required: true),
Param("timeout", typeof(int)));
var bound = Bind(eval, "obj=null;");
Assert.AreEqual("obj=null;", bound["code"].Value<string>());
}
[Test]
public void TryBind_DoubleDash_EndsFlagParsingAndExemptsTheRest()
{
var command = Command("two_slots",
Param("a", typeof(string), required: true),
Param("b", typeof(string)));
// After --, `-x` is a value and `a=1` is not a bare assignment.
var bound = Bind(command, "--", "-x", "a=1");
Assert.AreEqual("-x", bound["a"].Value<string>());
Assert.AreEqual("a=1", bound["b"].Value<string>());
}
// ---------------------------------------------------------------- boundaries
[Test]
public void TryBind_MissingRequiredParameter_IsNotABinderProblem()
{
// Required-parameter checking stays in ValidateCommandParameters so the message
// and error type are byte-identical across both request forms.
var bound = Bind(LogEditor());
Assert.AreEqual(0, bound.Count);
}
// ---------------------------------------------------------------- coercion (dry run)
/// <summary>Declared locally so the binder's enum handling is testable without
/// touching DummyCommands, which other suites share.</summary>
public enum SampleMode { Editor, Player, Headless }
[Test]
public void TryBind_IntParameterGivenNonNumericToken_ReportsTypeMismatch()
{
// Without this, ConvertParameterToken throws inside the executor and the request
// dies as a 500-ish "Command Execution Failed" instead of a usage error.
var command = Command("test_types", Param("count", typeof(int), defaultValue: 1));
var problems = BindProblems(command, "--count", "abc");
Assert.AreEqual(1, problems.Count);
Assert.AreEqual(ArgProblemKind.TypeMismatch, problems[0].Kind);
Assert.AreEqual("count", problems[0].Name);
Assert.AreEqual("abc", problems[0].Token);
Assert.AreEqual("Int32", problems[0].ExpectedType);
}
[Test]
public void TryBind_IntParameterGivenNumericToken_BindsCleanly()
{
var command = Command("test_types", Param("count", typeof(int), defaultValue: 1));
Assert.AreEqual("5", Bind(command, "--count", "5")["count"].Value<string>());
}
[Test]
public void TryBind_FloatParameterGivenDecimalToken_BindsCleanly()
{
var command = Command("test_types", Param("factor", typeof(float), defaultValue: 1.0f));
Assert.AreEqual("1.5", Bind(command, "--factor", "1.5")["factor"].Value<string>());
}
[Test]
public void TryBind_StringParameterGivenLeadingZeros_KeepsThemAsText()
{
// Regression guard, not a driver: this already holds because the binder stores
// strings. It is the semantic FIX over the CLI's old shape-guessing ParseValue,
// which turned 007 into the number 7 even for a declared string parameter.
var command = Command("reload_file", Param("path", typeof(string), required: true));
Assert.AreEqual("007", Bind(command, "007")["path"].Value<string>());
}
[Test]
public void TryBind_BoolParameterAsBareFlag_BindsTrue()
{
var command = Command("test_types", Param("enabled", typeof(bool), defaultValue: false));
var bound = Bind(command, "--enabled");
Assert.IsTrue(JToken.DeepEquals(new JObject { ["enabled"] = true }, bound),
"a bare flag on a bool must match the structured {\"enabled\":true}");
}
[Test]
public void TryBind_NonBoolParameterAsBareFlag_IsReportedNotCoercedToTheStringTrue()
{
// JValue(true).ToObject<string>() silently yields "True". Catching it here is what
// stops `log_editor --message` from logging the word True.
var problems = BindProblems(LogEditor(), "--message");
Assert.AreEqual(1, problems.Count);
Assert.AreEqual(ArgProblemKind.EmptyValue, problems[0].Kind,
"the user's mistake is a missing value, so EmptyValue reads better than TypeMismatch");
Assert.AreEqual("message", problems[0].Name);
}
// Enum binding defers entirely to the executor's own converter, so these cases ARE the
// parity contract: whatever the structured path accepts, the raw path must accept too.
[TestCase(typeof(SampleMode), "player", TestName = "TryBind_EnumName_IsAccepted")]
[TestCase(typeof(Severity), "warning", TestName = "TryBind_EnumName_IsAcceptedCaseInsensitively")]
// A [Flags] enum takes a comma-separated combination natively. A dry run that reimplemented
// name matching found no single name equal to "Info,Warning" and rejected a request the
// structured path accepts.
[TestCase(typeof(Channels), "Info,Warning", TestName = "TryBind_FlagsEnumCombination_IsAccepted")]
public void TryBind_EnumValueTheConverterAccepts_BindsTheTokenVerbatim(
System.Type parameterType, string token)
{
var command = Command("log", Param("level", parameterType, required: true));
// Stored as the token's own text, like every other declared type: the executor
// re-converts from the same string, so binding and execution cannot diverge.
Assert.IsTrue(JToken.DeepEquals(new JObject { ["level"] = token }, Bind(command, token)));
}
// The point of deferring to the converter is parity, not leniency. ExpectedType must name
// the enum itself, never Nullable`1.
[TestCase(typeof(SampleMode), "sideways", "SampleMode",
TestName = "TryBind_UnknownEnumName_ReportsTypeMismatch")]
[TestCase(typeof(Severity), "Verbose", "Severity",
TestName = "TryBind_UnknownEnumName_NamesTheEnum")]
[TestCase(typeof(Severity?), "Verbose", "Severity",
TestName = "TryBind_UnknownEnumNameOnANullableEnum_NamesTheUnderlyingType")]
public void TryBind_EnumValueTheConverterRejects_ReportsTypeMismatch(
System.Type parameterType, string token, string expectedTypeName)
{
var command = Command("log", Param("level", parameterType, required: true));
var problems = BindProblems(command, token);
Assert.AreEqual(ArgProblemKind.TypeMismatch, problems[0].Kind);
Assert.AreEqual("level", problems[0].Name);
Assert.AreEqual(token, problems[0].Token);
Assert.AreEqual(expectedTypeName, problems[0].ExpectedType);
}
[Test]
public void TryBind_JsonObjectStringForStructuredParameter_BindsCleanlyAndStaysAString()
{
// The '{'-prefixed JSON re-parse guard in ConvertParameterToken must be exercised by
// the dry run, and the dry run must NOT rewrite what we store -- the executor redoes
// the conversion from the same string.
var command = Command("batch", Param("operations", typeof(JObject), required: true));
var bound = Bind(command, "{\"a\":1}");
Assert.AreEqual(JTokenType.String, bound["operations"].Type);
Assert.AreEqual("{\"a\":1}", bound["operations"].Value<string>());
}
// ------------------------------------------------------- untyped parameters (Risk #4)
// 14 parameters across 12 shipped commands declare JToken/JObject/JArray — e.g.
// set_serialized_field.value, add_animator_parameter.defaultValue. For a DECLARED type the
// token's shape is irrelevant (a string coerces to int identically), but an untyped
// parameter has nothing to coerce toward, so the shape is the only signal there is. The CLI
// used to guess it client-side; dropping that guess would silently turn a working
// `--value 5` into the string "5". These cases reproduce the CLI's rules exactly.
[TestCase("5", JTokenType.Integer, TestName = "TryBind_UntypedInteger_KeepsItsJsonType")]
[TestCase("1.5", JTokenType.Float, TestName = "TryBind_UntypedFloat_KeepsItsJsonType")]
[TestCase("-17", JTokenType.Integer, TestName = "TryBind_UntypedNegativeInteger_KeepsItsJsonType")]
[TestCase("true", JTokenType.Boolean, TestName = "TryBind_UntypedBoolean_KeepsItsJsonType")]
// The old rule lower-cased before comparing.
[TestCase("TRUE", JTokenType.Boolean, TestName = "TryBind_UntypedUppercaseBoolean_KeepsItsJsonType")]
// 07 is a number (digit-accumulated, no radix handling), 1.2.3 is not, a bare dash is not.
[TestCase("07", JTokenType.Integer, TestName = "TryBind_UntypedLeadingZeroInteger_KeepsItsJsonType")]
[TestCase("1.2.3", JTokenType.String, TestName = "TryBind_UntypedSecondDot_StaysAString")]
[TestCase("-", JTokenType.String, TestName = "TryBind_UntypedBareDash_StaysAString")]
[TestCase("Assets/Foo.prefab", JTokenType.String, TestName = "TryBind_UntypedAssetPath_StaysAString")]
public void TryBind_ScalarForAnUntypedParameter_KeepsItsJsonType(
string token, JTokenType expected)
{
var command = Command("set_serialized_field", Param("value", typeof(JToken), required: true));
Assert.AreEqual(expected, Bind(command, token)["value"].Type);
}
[Test]
public void TryBind_UntypedLeadingZeroInteger_ParsesWithoutRadix()
{
// Shape alone is not the whole contract: 07 must become 7, not 8 (octal) or "07".
var command = Command("set_serialized_field", Param("value", typeof(JToken), required: true));
Assert.AreEqual(7, Bind(command, "07")["value"].Value<int>());
}
// The counterpart: a DECLARED type means the shape must NOT be guessed. This is the
// semantic fix over the old client-side guess, which turned 007 into the number 7 even for
// a declared string.
[TestCase(typeof(string), "007", TestName = "TryBind_DeclaredStringGivenDigits_StaysAString")]
[TestCase(typeof(int), "5", TestName = "TryBind_DeclaredIntGivenDigits_IsStillStoredAsAString")]
public void TryBind_ScalarForATypedParameter_StaysAStringRegardlessOfShape(
System.Type parameterType, string token)
{
var command = Command("reload_file", Param("value", parameterType, required: true));
Assert.AreEqual(JTokenType.String, Bind(command, token)["value"].Type);
}
[Test]
public void TryBind_UnparseableJsonForStructuredParameter_ReportsTypeMismatch()
{
// The silent-drop class: ToObject returns NULL rather than throwing, so today the
// parameter vanishes and the command runs with a default. That must be an error.
var command = Command("batch", Param("operations", typeof(float[]), required: true));
var problems = BindProblems(command, "not-json-at-all");
Assert.AreEqual(ArgProblemKind.TypeMismatch, problems[0].Kind);
Assert.AreEqual("operations", problems[0].Name);
}
// ------------------------------------------------- accumulation across value defects
[Test]
public void TryBind_DuplicateWhoseSecondUseIsEmpty_ReportsBothDefects()
{
// Accumulation is this class's contract: a user fixing a command line must see every
// defect at once. The empty-value branch used to return before the duplicate check,
// so resolving the reported EmptyValue left a still-unreported duplicate behind.
var problems = BindProblems(LogEditor(), "--message", "hi", "--message=");
Assert.AreEqual(2, problems.Count, $"expected both defects, got: {Describe(problems)}");
CollectionAssert.AreEquivalent(
new[] { ArgProblemKind.Duplicate, ArgProblemKind.EmptyValue },
new[] { problems[0].Kind, problems[1].Kind });
}
// ------------------------------------------------- positional overflow classification
/// <summary>a is required, b is optional - two slots, so three values is one too many.</summary>
private static CommandInfo TwoSlots() =>
Command("two_slots",
Param("a", typeof(string), required: true),
Param("b", typeof(string)));
[Test]
public void TryBind_OverflowPastASlotAFlagDidNotFill_IsExcessPositional()
{
// `--a X Y Z`: Y takes slot b, so Z overflows because the command has two slots and
// three values were supplied - NOT because Z collided with the --a flag. Classifying
// it as a conflict told the client something factually untrue about Z.
var problems = BindProblems(TwoSlots(), "--a", "X", "Y", "Z");
Assert.AreEqual(1, problems.Count, Describe(problems));
Assert.AreEqual(ArgProblemKind.ExcessPositional, problems[0].Kind);
Assert.AreEqual("Z", problems[0].Token);
Assert.AreEqual(1, problems[0].Capacity, "one slot was left free after --a took its own");
Assert.AreEqual(2, problems[0].Given, "capacity and given must be commensurable: free slots vs positionals");
}
[Test]
public void TryBind_PositionalWhoseOwnSlotAFlagFilled_IsAConflictNamingThatParameter()
{
// One slot, filled by --message, then a positional with nowhere to go. This IS the
// conflict case, and the rendered message reads "<name> is already set by --<name>",
// so Name has to be populated or the client prints an empty subject.
var problems = BindProblems(LogEditor(), "--message", "hi", "orphan");
Assert.AreEqual(1, problems.Count, Describe(problems));
Assert.AreEqual(ArgProblemKind.PositionalConflict, problems[0].Kind);
Assert.AreEqual("message", problems[0].Name, "the conflicting parameter must be named");
Assert.AreEqual("orphan", problems[0].Token);
}
[Test]
public void TryBind_ExcessPositional_NamesTheFirstTokenThatOverflowed()
{
// The prose renders "... but 3 were given (starting at 'X')", so the token has to be
// the first one with no slot, not the last one supplied.
var problems = BindProblems(LogEditor(), "one", "two", "three");
Assert.AreEqual(1, problems.Count, Describe(problems));
Assert.AreEqual(ArgProblemKind.ExcessPositional, problems[0].Kind);
Assert.AreEqual("two", problems[0].Token, "'one' filled the only slot, so 'two' is the first excess");
Assert.AreEqual(1, problems[0].Capacity);
Assert.AreEqual(3, problems[0].Given);
}
// ------------------------------------------------- bare assignment precision
/// <summary>eval: a required code string plus an optional integer timeout.</summary>
private static CommandInfo Eval() =>
Command("eval",
Param("code", typeof(string), required: true),
Param("timeout", typeof(int)));
[Test]
public void TryBind_PositionalAssigningAParameterOtherThanItsOwnSlot_IsAValue()
{
// `eval "timeout=5;"` is legal C#. It lands on the code slot, and code is not what
// the text before `=` names, so nothing about it suggests a misremembered flag.
// Matching ANY declared name rejected it, while the identical structured request
// {"code":"timeout=5;"} succeeded.
var bound = Bind(Eval(), "timeout=5;");
Assert.IsTrue(JToken.DeepEquals(new JObject { ["code"] = "timeout=5;" }, bound),
$"expected the token to bind as code, got {bound}");
}
[Test]
public void TryBind_PositionalAssigningItsOwnSlot_IsStillABareAssignment()
{
// `create_folder path=Assets/Foo` is the case the check exists for: the token lands
// on `path` and its own text names `path`, which is a misremembered `--path`.
var command = Command("create_folder", Param("path", typeof(string), required: true));
var problems = BindProblems(command, "path=Assets/Foo");
Assert.AreEqual(ArgProblemKind.BareAssignment, problems[0].Kind);
Assert.AreEqual("path=Assets/Foo", problems[0].Token);
}
[Test]
public void TryBind_PositionalAfterTheSeparator_IsNeverABareAssignment()
{
// The documented escape hatch, and what makes the residual ambiguity of a token that
// assigns its own slot (`eval "code=1;"`) recoverable rather than fatal.
var bound = Bind(Eval(), "--", "code=1;");
Assert.IsTrue(JToken.DeepEquals(new JObject { ["code"] = "code=1;" }, bound),
$"expected the token to bind as code, got {bound}");
}
// ------------------------------------------------- untyped targets
[Test]
public void TryBind_BareFlagOnAJsonObjectParameter_IsAnEmptyValue()
{
// A JObject cannot hold `true`, so a bare `--metadata` is a missing value, not a type
// error. The untyped exemption covered every JToken subtype, so the implied `true`
// reached the converter and surfaced as TypeMismatch instead.
var command = Command("tag", Param("metadata", typeof(JObject)));
var problems = BindProblems(command, "--metadata");
Assert.AreEqual(1, problems.Count, Describe(problems));
Assert.AreEqual(ArgProblemKind.EmptyValue, problems[0].Kind);
Assert.AreEqual("metadata", problems[0].Name);
}
[Test]
public void TryBind_BareFlagOnAFullyUntypedParameter_IsStillAccepted()
{
// `object` and bare `JToken` CAN hold true, so the exemption still applies to them.
var command = Command("tag", Param("anything", typeof(object)));
Assert.IsTrue(JToken.DeepEquals(new JObject { ["anything"] = true },
Bind(command, "--anything")));
}
[Test]
public void TryBind_TypeMismatchOnAnUntypedTarget_EchoesTheTokenTheUserTyped()
{
// An untyped target stores a literal-parsed JValue, so the stored value is not a
// string and the token text was dropped - rendering "expects JObject, but got ''".
var command = Command("tag", Param("metadata", typeof(JObject)));
var problems = BindProblems(command, "--metadata", "5");
Assert.AreEqual(1, problems.Count, Describe(problems));
Assert.AreEqual(ArgProblemKind.TypeMismatch, problems[0].Kind);
Assert.AreEqual("5", problems[0].Token, "the diagnostic must name what the user typed");
}
[Test]
public void TryBind_TypeMismatchOnAnUntypedTarget_EchoesTheTokenVerbatim()
{
// The literal rules turn `007` into 7, so echoing the stored value would misquote the
// user back at themselves.
var command = Command("tag", Param("metadata", typeof(JObject)));
var problems = BindProblems(command, "--metadata", "007");
Assert.AreEqual("007", problems[0].Token);
}
// ------------------------------------------------- enums
private enum Severity { Info = 1, Warning = 2, Error = 4 }
[System.Flags]
private enum Channels { None = 0, Info = 1, Warning = 2, Error = 4 }
[Test]
public void TryBind_EnumGivenAsAFlagValue_ReachesTheSameConverter()
{
// The table above binds enums positionally; a --flag value takes a different path
// through pass 1, so pin that it lands on the same coercion check.
var command = Command("log", Param("channels", typeof(Channels)));
Assert.IsTrue(JToken.DeepEquals(new JObject { ["channels"] = "Info,Warning" },
Bind(command, "--channels", "Info,Warning")));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: dfbc17c1da9dcd941b413e7e8f7f6c54
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,112 @@
using System.Collections.Generic;
using NUnit.Framework;
using Unity.Pipeline;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Unit tests for CommandLineTokenizer - the POSIX-ish lexer that splits a raw
/// <c>commandLine</c> string into argv tokens before binding.
///
/// These tests are the written form of the dialect contract: the dialect is the
/// pipeline's OWN, identical on every OS, and documented in connectivity.md. Changing
/// any expectation here is a wire-contract change, not a refactor. In particular the
/// <c>WindowsDoubledQuoteIsNotAnEscape</c> case pins us to POSIX rules over
/// <c>CommandLineToArgvW</c>.
///
/// The dialect is a pure input-to-output function, so the cases are a table rather than
/// ~18 near-identical methods. Each case keeps its own name and, where the rule is not
/// self-evident, the reason it is that way.
/// </summary>
class CommandLineTokenizerTests
{
private static List<string> Tokenize(string commandLine)
{
Assert.IsTrue(CommandLineTokenizer.TryTokenize(commandLine, out var tokens, out var error),
$"Expected '{commandLine}' to tokenize, but failed with: {error}");
Assert.IsNull(error, "A successful tokenize must not report an error");
return tokens;
}
private static TestCaseData Case(string name, string commandLine, params string[] expected)
{
return new TestCaseData(commandLine, expected).SetName($"TryTokenize_{name}");
}
private static IEnumerable<TestCaseData> TokenizeCases()
{
yield return Case("SimpleWordsSplitOnWhitespace",
"eval return 2+2;", "eval", "return", "2+2;");
// Leading, trailing, tabs and repeated spaces all collapse.
yield return Case("WhitespaceRunsProduceNoEmptyTokens", " a \t b ", "a", "b");
// A pure lexer has no opinion about needing a command name; the caller rejects an
// empty token list so the message matches the structured path.
yield return Case("EmptyStringYieldsNoTokens", "");
yield return Case("WhitespaceOnlyYieldsNoTokens", " ");
yield return Case("NullYieldsNoTokens", null);
// eval "return 2+2;"
yield return Case("DoubleQuotesGroupWhitespaceIntoOneToken",
"eval \"return 2+2;\"", "eval", "return 2+2;");
// eval 'obj.name = "x";' <- the reason single quotes are load-bearing
yield return Case("SingleQuotesGroupWhitespaceAndPassDoubleQuotesThrough",
"eval 'obj.name = \"x\";'", "eval", "obj.name = \"x\";");
// "a\"b" -> a"b
yield return Case("BackslashInsideDoubleQuotesEscapesNextCharacter",
"\"a\\\"b\"", "a\"b");
// 'a\b' -> a\b (single quotes have NO escapes at all)
yield return Case("BackslashInsideSingleQuotesIsLiteral", "'a\\b'", "a\\b");
// a\ b -> one token "a b"
yield return Case("BackslashOutsideQuotesEscapesNextCharacter", "a\\ b", "a b");
yield return Case("SingleQuoteInsideDoubleQuotesIsLiteral", "\"it's\"", "it's");
// a"b c"d -> ab cd
yield return Case("AdjacentSpansConcatenateIntoOneToken", "a\"b c\"d", "ab cd");
// "" -> a single EMPTY token, not zero tokens. This is what lets `--message ""`
// mean the empty string instead of the boolean true.
yield return Case("EmptyQuotedSpanYieldsOneEmptyToken", "\"\"", string.Empty);
// PINS THE DIALECT. Windows CommandLineToArgvW reads "a""b" as a"b. POSIX-ish closes
// the span and opens a new one, so it concatenates to ab.
yield return Case("WindowsDoubledQuoteIsNotAnEscape", "\"a\"\"b\"", "ab");
// No expansion of any kind: no operators, no $VAR, no globbing, no ~.
yield return Case("ShellOperatorsAreLiteralTokens",
"a && b | c > d $HOME *.cs ~",
"a", "&&", "b", "|", "c", ">", "d", "$HOME", "*.cs", "~");
yield return Case("UnityAssetPathSurvivesUnchanged",
"reload_file Assets/Scripts/Player.cs", "reload_file", "Assets/Scripts/Player.cs");
// The tokenizer does not interpret flags; the binder does.
yield return Case("FlagsAndDoubleDashArePlainTokens",
"log_editor --message hi -- -x", "log_editor", "--message", "hi", "--", "-x");
}
[TestCaseSource(nameof(TokenizeCases))]
public void TryTokenize_ProducesTheExpectedTokens(string commandLine, string[] expected)
{
CollectionAssert.AreEqual(expected, Tokenize(commandLine));
}
[TestCase("eval \"unbalanced", "quote", TestName = "TryTokenize_UnbalancedDoubleQuote_IsRejected")]
[TestCase("eval 'unbalanced", "quote", TestName = "TryTokenize_UnbalancedSingleQuote_IsRejected")]
[TestCase("abc\\", "backslash", TestName = "TryTokenize_DanglingBackslash_IsRejected")]
public void TryTokenize_MalformedInput_IsRejectedWithAnExplanation(
string commandLine, string expectedInMessage)
{
Assert.IsFalse(CommandLineTokenizer.TryTokenize(commandLine, out _, out var error),
$"Expected '{commandLine}' to be rejected");
Assert.IsNotEmpty(error, "A failed tokenize must explain why");
StringAssert.Contains(expectedInMessage, error.ToLowerInvariant());
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 869902abb647be9469c368bab58d18db
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,389 @@
using System.Linq;
using System.Reflection;
using NUnit.Framework;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Tests for command registration system using [CliCommand] and [CliArg] attributes.
/// These test the attribute-based command definition that CLI tools will discover.
/// </summary>
class CommandRegistrationTests
{
[SetUp]
public void SetUp()
{
// Ensure TypeCache discovery is available for tests
CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
}
[Test]
public void CliCommandAttribute_AppliedToMethod_CanBeRetrievedViaReflection()
{
// Arrange - Get the test command method (private static, resolved via reflection)
var methodInfo = typeof(DummyCommands).GetMethod("TestLogCommand",
BindingFlags.NonPublic | BindingFlags.Static);
Assert.IsNotNull(methodInfo, "Test command method should exist");
// Act - Try to get the CliCommand attribute
var attribute = methodInfo.GetCustomAttribute<CliCommandAttribute>();
// Assert - Attribute should be present and contain expected data
Assert.IsNotNull(attribute, "Method should have CliCommand attribute");
Assert.AreEqual("log_editor", attribute.Name, "Command name should match attribute");
Assert.AreEqual("Log a message to Unity Editor console", attribute.Description,
"Command description should match attribute");
}
[Test]
public void CliArgAttribute_AppliedToParameter_CanBeRetrievedViaReflection()
{
// Arrange - Get the test command method and its parameters (private static)
var methodInfo = typeof(DummyCommands).GetMethod("TestLogCommand",
BindingFlags.NonPublic | BindingFlags.Static);
var parameters = methodInfo.GetParameters();
Assert.AreEqual(1, parameters.Length, "Test command should have one parameter");
// Act - Try to get the CliArg attribute from the parameter
var parameter = parameters[0];
var attribute = parameter.GetCustomAttribute<CliArgAttribute>();
// Assert - Parameter attribute should be present and contain expected data
Assert.IsNotNull(attribute, "Parameter should have CliArg attribute");
Assert.AreEqual("message", attribute.Name, "Parameter name should match attribute");
Assert.AreEqual("The message to log", attribute.Description,
"Parameter description should match attribute");
Assert.IsTrue(attribute.Required, "Parameter should be marked as required");
}
[Test]
public void CommandRegistry_DiscoverCommands_FindsRegisteredCommands()
{
// Arrange & Act - Discover all commands via CommandRegistry
var commands = CommandRegistry.DiscoverCommands();
// Assert - Should find our test command
Assert.IsNotNull(commands, "Command discovery should return a collection");
Assert.Greater(commands.Count(), 0, "Should discover at least one command");
// Find our specific test command
var testCommand = commands.FirstOrDefault(cmd => cmd.Name == "log_editor");
Assert.IsNotNull(testCommand, "Should discover the log_editor test command");
Assert.AreEqual("Log a message to Unity Editor console", testCommand.Description);
Assert.IsTrue(testCommand.MainThreadRequired, "Test command should require main thread");
// Verify parameter information
Assert.AreEqual(1, testCommand.Parameters.Count, "Command should have one parameter");
var messageParam = testCommand.Parameters.First();
Assert.AreEqual("message", messageParam.Name);
Assert.AreEqual("The message to log", messageParam.Description);
Assert.IsTrue(messageParam.Required);
Assert.AreEqual(typeof(string), messageParam.ParameterType);
}
[Test]
public void CommandRegistry_DiscoverCommands_PopulatesRuntimeOnlyFlag()
{
// Arrange & Act
var commands = CommandRegistry.DiscoverCommands().ToList();
// Assert - runtime commands are tagged RuntimeOnly, editor commands are not
var editorStatus = commands.First(c => c.Name == "editor_status");
var runtimeStatus = commands.First(c => c.Name == "runtime_status");
Assert.IsTrue(runtimeStatus.RuntimeOnly, "eval should be marked RuntimeOnly");
Assert.IsFalse(editorStatus.RuntimeOnly, "editor_status should not be RuntimeOnly");
}
[Test]
public void CommandRegistry_DiscoverCommands_PopulatesTagsFromAttribute()
{
// Arrange & Act
var commands = CommandRegistry.DiscoverCommands();
// Assert - path-style tags flow from [CliCommand(Tags = ...)] into CommandInfo
var tagged = commands.FirstOrDefault(cmd => cmd.Name == "test_tagged");
Assert.IsNotNull(tagged, "Should discover the test_tagged test command");
CollectionAssert.AreEqual(new[] { "test/registration", "test" }, tagged.Tags,
"Tags should flow from the [CliCommand] attribute into CommandInfo");
}
[Test]
public void CommandRegistry_DiscoverCommands_UntaggedCommandHasEmptyTags()
{
// Arrange & Act
var commands = CommandRegistry.DiscoverCommands();
// Assert - commands without Tags get an empty (never null) list
var untagged = commands.First(cmd => cmd.Name == "log_editor");
Assert.IsNotNull(untagged.Tags, "Tags should never be null");
Assert.AreEqual(0, untagged.Tags.Count, "Untagged command should have no tags");
}
[Test]
public void CommandRegistry_DiscoverCommands_AllShippedCommandsCarryTags()
{
// Arrange & Act
var commands = CommandRegistry.DiscoverCommands();
// Assert - every command shipped in the package assemblies is browsable by tag.
// Test-fixture commands (Unity.Pipeline.Tests.*) are exempt: several tests
// depend on deliberately untagged fixtures (e.g. log_editor).
var shippedAssemblies = new[] { "Unity.Pipeline", "Unity.Pipeline.Editor" };
var untagged = commands
.Where(cmd => shippedAssemblies.Contains(cmd.Package))
.Where(cmd => cmd.Tags.Count == 0)
.Select(cmd => cmd.Name)
.OrderBy(name => name)
.ToList();
Assert.IsEmpty(untagged,
$"Shipped commands must declare at least one tag: {string.Join(", ", untagged)}");
}
[Test]
public void CommandRegistry_DiscoverCommands_TagsAreWellFormedPaths()
{
// Arrange & Act
var commands = CommandRegistry.DiscoverCommands();
// Assert - tags are lowercase '/'-separated paths so tag-subtree filtering
// and group_by=tag nesting behave predictably
var pattern = new System.Text.RegularExpressions.Regex("^[a-z0-9_]+(/[a-z0-9_]+)*$");
var malformed = commands
.SelectMany(cmd => cmd.Tags
.Where(tag => !pattern.IsMatch(tag))
.Select(tag => $"{cmd.Name}: '{tag}'"))
.ToList();
Assert.IsEmpty(malformed,
$"Tags must be lowercase path-style segments: {string.Join(", ", malformed)}");
}
[Test]
public void CommandRegistry_DiscoverCommands_DerivesPackageFromDeclaringAssembly()
{
// Arrange & Act
var commands = CommandRegistry.DiscoverCommands().ToList();
// Assert - each command carries the assembly it originates from
var testCommand = commands.First(cmd => cmd.Name == "log_editor");
Assert.AreEqual("Unity.Pipeline.Tests.Editor", testCommand.Package,
"Test-assembly command should report the test assembly as its package");
var editorCommand = commands.First(cmd => cmd.Name == "editor_status");
Assert.AreEqual("Unity.Pipeline.Editor", editorCommand.Package,
"Editor command should report the editor assembly as its package");
}
[Test]
public void JsonSchemaGeneration_FromCommandInfo_CreatesValidSchema()
{
// Arrange - Get our test command
var commands = CommandRegistry.DiscoverCommands();
var testCommand = commands.First(cmd => cmd.Name == "log_editor");
// Act - Generate JSON schema for the command
var schema = JsonSchemaGenerator.GenerateCommandSchema(testCommand);
// Assert - Schema should be valid JSON Schema format
Assert.IsNotNull(schema, "Schema should not be null");
// Parse as JSON to verify structure
var schemaJson = JObject.Parse(schema);
// Verify root schema properties
Assert.AreEqual("object", schemaJson["type"]?.ToString());
Assert.IsNotNull(schemaJson["properties"], "Schema should have properties section");
Assert.IsNotNull(schemaJson["required"], "Schema should have required array");
// Verify command metadata
Assert.AreEqual("log_editor", schemaJson["title"]?.ToString());
Assert.AreEqual("Log a message to Unity Editor console", schemaJson["description"]?.ToString());
// Verify parameter schema
var properties = schemaJson["properties"] as JObject;
Assert.IsNotNull(properties["message"], "Should have message parameter");
var messageProperty = properties["message"] as JObject;
Assert.AreEqual("string", messageProperty["type"]?.ToString());
Assert.AreEqual("The message to log", messageProperty["description"]?.ToString());
// Verify required array
var required = schemaJson["required"] as JArray;
Assert.Contains("message", required.ToObject<string[]>());
}
[Test]
public void JsonSchemaGeneration_MultipleParameterTypes_MapsTypesCorrectly()
{
// Arrange - Get test command with multiple parameter types
var commands = CommandRegistry.DiscoverCommands();
var testCommand = commands.First(cmd => cmd.Name == "test_types");
// Act - Generate schema
var schema = JsonSchemaGenerator.GenerateCommandSchema(testCommand);
var schemaJson = JObject.Parse(schema);
// Assert - Verify different parameter types are mapped correctly
var properties = schemaJson["properties"] as JObject;
// Integer parameter
var countProperty = properties["count"] as JObject;
Assert.AreEqual("integer", countProperty["type"]?.ToString());
Assert.AreEqual(1, countProperty["default"]?.ToObject<int>());
// Boolean parameter
var enabledProperty = properties["enabled"] as JObject;
Assert.AreEqual("boolean", enabledProperty["type"]?.ToString());
Assert.AreEqual(false, enabledProperty["default"]?.ToObject<bool>());
// Float parameter
var factorProperty = properties["factor"] as JObject;
Assert.AreEqual("number", factorProperty["type"]?.ToString());
Assert.AreEqual(1.0f, factorProperty["default"]?.ToObject<float>());
// Verify no required parameters (all have defaults)
var required = schemaJson["required"] as JArray;
Assert.AreEqual(0, required.Count, "All parameters should be optional with default values");
}
[Test]
public void JsonSchemaGeneration_StructuredObjectParameter_EmitsNestedObjectSchema()
{
// Arrange - command whose single parameter is an IStructuredCommandInput DTO
var commands = CommandRegistry.DiscoverCommands();
var testCommand = commands.First(cmd => cmd.Name == "test_structured");
// Act
var schemaJson = JObject.Parse(JsonSchemaGenerator.GenerateCommandSchema(testCommand));
// Assert - the payload param is a real nested object schema, not a "string" fallback
var payload = schemaJson["properties"]?["payload"] as JObject;
Assert.IsNotNull(payload, "payload parameter should be present");
Assert.AreEqual("object", payload["type"]?.ToString(), "structured param should map to object");
Assert.AreEqual(false, payload["additionalProperties"]?.ToObject<bool>());
var props = payload["properties"] as JObject;
Assert.IsNotNull(props, "nested object should expose its own properties");
// Scalar member with description from [CliArg]
Assert.AreEqual("string", props["name"]?["type"]?.ToString());
Assert.AreEqual("Display name", props["name"]?["description"]?.ToString());
Assert.AreEqual("integer", props["count"]?["type"]?.ToString());
// Array member emits items schema
Assert.AreEqual("array", props["tags"]?["type"]?.ToString());
Assert.AreEqual("string", props["tags"]?["items"]?["type"]?.ToString());
// Nested structured member recurses
var nested = props["nested"] as JObject;
Assert.IsNotNull(nested, "nested structured member should be present");
Assert.AreEqual("object", nested["type"]?.ToString());
Assert.AreEqual("boolean", nested["properties"]?["enabled"]?["type"]?.ToString());
// Required propagates from [CliArg(Required = true)] on a member
var required = payload["required"]?.ToObject<string[]>();
Assert.IsNotNull(required, "structured object should have a required array");
CollectionAssert.Contains(required, "name");
}
[Test]
public void EditorStatusCommand_IsDiscovered_ByCommandRegistry()
{
// Arrange & Act - Discover all commands
var commands = CommandRegistry.DiscoverCommands();
// Assert - Should find the editor_status command
var editorStatusCommand = commands.FirstOrDefault(cmd => cmd.Name == "editor_status");
Assert.IsNotNull(editorStatusCommand, "Should discover the editor_status command");
Assert.AreEqual("Get detailed Unity Editor status and state information", editorStatusCommand.Description);
Assert.IsTrue(editorStatusCommand.MainThreadRequired, "editor_status should require main thread");
// Verify it has no parameters (editor status doesn't need input)
Assert.AreEqual(0, editorStatusCommand.Parameters.Count, "editor_status should have no parameters");
}
[Test]
public void EditorStatusCommand_DirectExecution_WorksCorrectly()
{
// Arrange & Act - Execute editor_status command directly (bypass all HTTP and threading)
try
{
var result = Unity.Pipeline.Editor.Commands.EditorStatusCommand.GetEditorStatus();
// Assert
Assert.IsNotNull(result, "editor_status command should return a result");
Assert.IsInstanceOf<Unity.Pipeline.Models.StatusResponse>(result, "Should return StatusResponse type");
var status = result as Unity.Pipeline.Models.StatusResponse;
Assert.IsNotNull(status.Status, "Status should have Status field");
Assert.IsNotNull(status.UnityVersion, "Status should have UnityVersion field");
}
catch (System.Exception)
{
throw;
}
}
}
/// <summary>
/// Test commands for verifying command registration attributes.
/// </summary>
static class DummyCommands
{
// Private on purpose: verifies the registry can discover and execute
// non-public static methods (see CommandRegistry.CreateCommandInfo).
[CliCommand("log_editor", "Log a message to Unity Editor console")]
private static void TestLogCommand([CliArg("message", "The message to log", Required = true)] string message)
{
UnityEngine.Debug.Log(message);
}
[CliCommand("test_types", "Test command with various parameter types")]
public static void TestTypesCommand(
[CliArg("count", "Number of items")] int count = 1,
[CliArg("enabled", "Whether feature is enabled")] bool enabled = false,
[CliArg("factor", "Multiplier factor")] float factor = 1.0f)
{
// Test command with various types
}
[CliCommand("test_tagged", "Test command with hierarchical tags",
Tags = new[] { "test/registration", "test" })]
public static void TestTaggedCommand()
{
// Carries path-style tags to verify they flow into CommandInfo
}
[CliCommand("test_structured", "Test command with a structured object parameter")]
public static void TestStructuredCommand(
[CliArg("payload", "Structured payload")] SampleStructuredInput payload = null)
{
// Test command with a nested-object parameter
}
}
/// <summary>Structured-input DTO used to verify nested object schema generation.</summary>
class SampleStructuredInput : IStructuredCommandInput
{
[CliArg("name", "Display name", Required = true)]
public string Name { get; set; }
[CliArg("count", "How many")]
public int Count { get; set; }
[CliArg("tags", "Associated tags")]
public string[] Tags { get; set; }
[CliArg("nested", "Nested child object")]
public SampleNestedInput Nested { get; set; }
}
class SampleNestedInput : IStructuredCommandInput
{
[CliArg("enabled", "Toggle")]
public bool Enabled { get; set; }
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9a2753687e13238458ed342206a00d72
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ddbcf4b2309b5fe4eb468f2437e44809
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,87 @@
using System;
using System.Collections;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using NUnit.Framework;
using Unity.Pipeline.Console;
using Unity.Pipeline.Runtime.Commands;
using UnityEditor;
using UnityEngine;
using UnityEngine.TestTools;
namespace Unity.Pipeline.Tests.Editor.Console
{
class ConsoleCaptureResubscribeTests
{
[UnityTearDown]
public IEnumerator TearDown()
{
if (EditorApplication.isPlaying)
yield return new ExitPlayMode();
RearmConsoleLogCapture();
}
[UnityTest]
public IEnumerator ConsoleCapture_SurvivesPlayModeExit()
{
yield return new EnterPlayMode();
yield return new ExitPlayMode();
var marker = NewMarker("survives_exit");
EmitError(marker);
var consoleResponse = ConsoleCommand.GetConsole(level: "error");
Assert.IsTrue(consoleResponse.Entries.Any(e => e.Message != null && e.Message.Contains(marker)),
"console must still capture entries logged after exiting play mode");
}
[Test]
public void CaptureSurvivesLostSubscription_WhenRearmed()
{
StripConsoleLogCaptureSubscription();
var deadMarker = NewMarker("dead");
EmitError(deadMarker);
Assert.AreEqual(0, CountCaptures(deadMarker),
"Stripping the subscription should stop capture (sanity check on the strip step)");
RearmConsoleLogCapture();
var liveMarker = NewMarker("live");
EmitError(liveMarker);
Assert.AreEqual(1, CountCaptures(liveMarker),
"Re-arming the subscription must restore capture");
RearmConsoleLogCapture();
var idempotentMarker = NewMarker("idempotent");
EmitError(idempotentMarker);
Assert.AreEqual(1, CountCaptures(idempotentMarker),
"Re-arming an already-live subscription must not double-record a single log");
}
private static string NewMarker(string tag) => $"consolecapture_{tag}_{Guid.NewGuid():N}";
private static void EmitError(string marker)
{
LogAssert.Expect(LogType.Error, new Regex(".*" + Regex.Escape(marker) + ".*"));
Debug.LogError(marker);
}
private static void StripConsoleLogCaptureSubscription()
{
var method = typeof(ConsoleLogCapture).GetMethod("OnLogMessage", BindingFlags.NonPublic | BindingFlags.Static);
Assert.IsNotNull(method, "ConsoleLogCapture.OnLogMessage must exist for this test to strip its subscription");
var handler = (Application.LogCallback)Delegate.CreateDelegate(typeof(Application.LogCallback), method);
Application.logMessageReceivedThreaded -= handler;
}
private static void RearmConsoleLogCapture() => ConsoleLogCapture.EnsureCapturing();
private static int CountCaptures(string marker) =>
ConsoleLogCapture.Buffer.Query(-1, 0, ConsoleLogBuffer.SeverityLog)
.Entries.Count(e => e.Message != null && e.Message.Contains(marker));
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d89fccd0e0213ab4bbde1251233cd766
@@ -0,0 +1,243 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using NUnit.Framework;
using Unity.Pipeline.Console;
using Unity.Pipeline.Editor.Console;
using UnityEngine;
using UnityEngine.TestTools;
namespace Unity.Pipeline.Tests.Editor.Console
{
/// <summary>
/// Tests for the Editor console ground-truth sampler: what it publishes, that seeding leaves the
/// user's console filtering exactly as it found it, and that it re-arms capture when the console
/// moves and the buffer's cursor does not.
/// </summary>
class EditorConsoleGroundTruthTests
{
[TearDown]
public void TearDown()
{
// A failed watchdog test would otherwise leave capture dead for the rest of the run.
ConsoleLogCapture.EnsureCapturing();
}
[Test]
public void Sample_PublishesConsoleCountsAndCompilationFlag()
{
var marker = NewMarker("groundtruth");
EmitWarning(marker);
EditorConsoleGroundTruth.SampleForTests();
var truth = ConsoleLogCapture.GroundTruth;
Assert.IsNotNull(truth, "Sampling must publish a snapshot");
Assert.Greater(truth.ConsoleWarnings, 0, "The warning just logged is in the Editor console");
Assert.Less((DateTime.UtcNow - truth.SampledUtc).TotalSeconds, 30, "The snapshot should be the one just taken");
}
[Test]
public void Seed_RestoresConsoleFlagsAndFilterText()
{
if (!EditorConsoleEntries.Available)
Assert.Ignore("UnityEditor.LogEntries is not reachable on this Editor version");
var originalFlags = GetConsoleFlags();
var originalFilter = GetFilteringText();
try
{
// Restrict the console the way a user might: hide errors and set a search filter.
// Seeding has to force both open to see every entry, then put them back.
SetConsoleFlags(originalFlags & ~LogLevelError);
SetFilteringText("pipeline_seed_filter_probe");
var restrictedFlags = GetConsoleFlags();
var restrictedFilter = GetFilteringText();
EditorConsoleGroundTruth.ResetForTests();
EditorConsoleGroundTruth.SampleForTests();
Assert.AreEqual(restrictedFlags, GetConsoleFlags(), "Seeding must leave the console's level toggles as it found them");
Assert.AreEqual(restrictedFilter, GetFilteringText(), "Seeding must leave the console's search text as it found it");
}
finally
{
SetFilteringText(originalFilter);
SetConsoleFlags(originalFlags);
}
}
[Test]
public void Seed_DoesNotDuplicateEntriesAlreadyCaptured()
{
if (!EditorConsoleEntries.Available)
Assert.Ignore("UnityEditor.LogEntries is not reachable on this Editor version");
var marker = NewMarker("dedupe");
EmitError(marker);
Assert.AreEqual(1, CountCaptures(marker), "The live callback captured it once");
EditorConsoleGroundTruth.ResetForTests();
EditorConsoleGroundTruth.SampleForTests();
Assert.AreEqual(1, CountCaptures(marker), "Seeding must not re-add an entry the buffer already holds");
}
[Test]
public void Seed_RecoversEntriesLoggedWhileCaptureWasDead()
{
if (!EditorConsoleEntries.Available)
Assert.Ignore("UnityEditor.LogEntries is not reachable on this Editor version");
// The repair path, and the shape of the reported symptom: the Editor console holds an
// error the buffer never received, because capture was not listening when it was logged.
// Editor compile errors are the real case — they are sticky, so they stand in the console
// long after the callback missed them.
StripSubscription();
var marker = NewMarker("missed");
EmitError(marker);
Assert.AreEqual(0, CountCaptures(marker),
"Precondition: the dead subscription must have missed it");
ConsoleLogCapture.EnsureCapturing();
EditorConsoleGroundTruth.ResetForTests();
EditorConsoleGroundTruth.SampleForTests();
Assert.AreEqual(1, CountCaptures(marker),
"Seeding must recover the entry the console holds and the buffer missed");
var seeded = ConsoleLogCapture.Buffer.Query(-1, 0, ConsoleLogBuffer.SeverityLog)
.Entries.First(e => e.Message != null && e.Message.Contains(marker));
Assert.IsTrue(seeded.Seeded,
"A recovered entry must be marked, since its timestamp is the read time");
}
[Test]
public void Seed_RecoversARepeatedEntryTheBufferMissed()
{
if (!EditorConsoleEntries.Available)
Assert.Ignore("UnityEditor.LogEntries is not reachable on this Editor version");
// Console messages repeat by design, so matching on presence alone is not enough: the
// copy the buffer already holds would cancel out the identical copy it missed, and that
// one would never be recovered.
var marker = NewMarker("repeated");
EmitError(marker);
Assert.AreEqual(1, CountCaptures(marker), "The live callback captured the first one");
StripSubscription();
EmitError(marker);
Assert.AreEqual(1, CountCaptures(marker),
"Precondition: the second, identical entry was missed while capture was dead");
ConsoleLogCapture.EnsureCapturing();
EditorConsoleGroundTruth.ResetForTests();
EditorConsoleGroundTruth.SampleForTests();
Assert.AreEqual(2, CountCaptures(marker),
"Both occurrences must be held after seeding, not just one");
}
[Test]
public void Seed_RecoversEveryOccurrenceOfAPreCaptureRepeat()
{
if (!EditorConsoleEntries.Available)
Assert.Ignore("UnityEditor.LogEntries is not reachable on this Editor version");
// A fresh seed with nothing held: all three identical rows have to come across.
StripSubscription();
var marker = NewMarker("triple");
EmitError(marker);
EmitError(marker);
EmitError(marker);
Assert.AreEqual(0, CountCaptures(marker), "Precondition: capture was dead for all three");
ConsoleLogCapture.EnsureCapturing();
EditorConsoleGroundTruth.ResetForTests();
EditorConsoleGroundTruth.SampleForTests();
Assert.AreEqual(3, CountCaptures(marker), "Every occurrence must be recovered");
}
[Test]
public void Watchdog_ReArmsCaptureWhenConsoleMovedAndBufferDidNot()
{
StripSubscription();
// Baseline, then a log the dead subscription cannot deliver: the console's count moves
// while the buffer's cursor stands still, which is the only symptom available.
EditorConsoleGroundTruth.SampleForTests();
EmitError(NewMarker("gap"));
EditorConsoleGroundTruth.SampleForTests();
EditorConsoleGroundTruth.SampleForTests();
var marker = NewMarker("recovered");
EmitError(marker);
Assert.AreEqual(1, CountCaptures(marker), "The watchdog must have re-armed capture");
}
[Test]
public void EditorConsoleEntries_ReadsRecentEntries()
{
if (!EditorConsoleEntries.Available)
Assert.Ignore("UnityEditor.LogEntries is not reachable on this Editor version");
var marker = NewMarker("read");
EmitError(marker);
var entries = new List<EditorConsoleEntries.Entry>();
Assert.IsTrue(EditorConsoleEntries.TryReadRecent(200, entries));
Assert.IsTrue(entries.Any(e => e.Message != null && e.Message.Contains(marker)),
"The entry just logged must be readable from the Editor console store");
}
// ConsoleMode.kLogLevelError, as mirrored by the internal ConsoleWindow.ConsoleFlags.
const int LogLevelError = 1 << 9;
static readonly Type k_LogEntries = Type.GetType("UnityEditor.LogEntries,UnityEditor");
static int GetConsoleFlags() => (int)Property("consoleFlags").GetGetMethod(true).Invoke(null, null);
static void SetConsoleFlags(int flags) => Property("consoleFlags").GetSetMethod(true).Invoke(null, new object[] { flags });
static string GetFilteringText() => (string)Method("GetFilteringText").Invoke(null, null) ?? string.Empty;
static void SetFilteringText(string text) => Method("SetFilteringText").Invoke(null, new object[] { text });
static PropertyInfo Property(string name) =>
k_LogEntries.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
static MethodInfo Method(string name) =>
k_LogEntries.GetMethod(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
static string NewMarker(string tag) => $"groundtruth_{tag}_{Guid.NewGuid():N}";
static void EmitError(string marker)
{
LogAssert.Expect(LogType.Error, new Regex(".*" + Regex.Escape(marker) + ".*"));
Debug.LogError(marker);
}
static void EmitWarning(string marker)
{
LogAssert.Expect(LogType.Warning, new Regex(".*" + Regex.Escape(marker) + ".*"));
Debug.LogWarning(marker);
}
static void StripSubscription()
{
var method = typeof(ConsoleLogCapture).GetMethod("OnLogMessage", BindingFlags.NonPublic | BindingFlags.Static);
Assert.IsNotNull(method, "ConsoleLogCapture.OnLogMessage must exist for this test to strip its subscription");
var handler = (Application.LogCallback)Delegate.CreateDelegate(typeof(Application.LogCallback), method);
Application.logMessageReceivedThreaded -= handler;
}
static int CountCaptures(string marker) =>
ConsoleLogCapture.Buffer.Query(-1, 0, ConsoleLogBuffer.SeverityLog)
.Entries.Count(e => e.Message != null && e.Message.Contains(marker));
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1b4a37e6cb5686043949dcba6e8f64ab
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,193 @@
using System;
using System.Linq;
using NUnit.Framework;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Console;
using Unity.Pipeline.Editor;
using Unity.Pipeline.Runtime.Commands;
using UnityEngine;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Tests for the console command (<see cref="ConsoleCommand"/>).
///
/// Entries are injected straight into the shared buffer via <see cref="ConsoleLogBuffer.Add"/>
/// (rather than real Debug.Log calls) so the tests are deterministic and don't trip Unity's
/// LogAssert on error-level logs. Each test tags its entries with a unique marker and filters on
/// it, so unrelated editor logs captured concurrently don't affect assertions.
/// </summary>
class ConsoleCommandTests
{
string m_Marker;
[SetUp]
public void SetUp()
{
ConsoleLogCapture.Buffer.Clear();
m_Marker = $"[ectest-{Guid.NewGuid():N}] ";
}
string[] MineInOrder(Unity.Pipeline.Models.ConsoleLogResponse response)
{
return response.Entries
.Where(e => e.Message != null && e.Message.StartsWith(m_Marker))
.Select(e => e.Message.Substring(m_Marker.Length))
.ToArray();
}
void Add(LogType type, string message) =>
ConsoleLogCapture.Buffer.Add(type, m_Marker + message, null, DateTime.UtcNow);
[Test]
public void GetConsole_Default_ReturnsRecentEntriesInOrder()
{
Add(LogType.Log, "one");
Add(LogType.Log, "two");
Add(LogType.Log, "three");
var response = ConsoleCommand.GetConsole();
CollectionAssert.AreEqual(new[] { "one", "two", "three" }, MineInOrder(response));
}
[Test]
public void GetConsole_LevelError_ReturnsOnlyErrorSeverity()
{
Add(LogType.Log, "plain");
Add(LogType.Warning, "careful");
Add(LogType.Error, "broken");
Add(LogType.Exception, "threw");
var response = ConsoleCommand.GetConsole(level: "error");
CollectionAssert.AreEqual(new[] { "broken", "threw" }, MineInOrder(response));
}
[Test]
public void GetConsole_LevelWarn_IncludesWarningsAndErrors()
{
Add(LogType.Log, "plain");
Add(LogType.Warning, "careful");
Add(LogType.Error, "broken");
var response = ConsoleCommand.GetConsole(level: "warn");
CollectionAssert.AreEqual(new[] { "careful", "broken" }, MineInOrder(response));
}
[Test]
public void GetConsole_Since_ReturnsOnlyNewerEntries()
{
Add(LogType.Log, "before");
long cursor = ConsoleCommand.GetConsole().Cursor;
Add(LogType.Log, "after");
var response = ConsoleCommand.GetConsole(since: cursor);
CollectionAssert.AreEqual(new[] { "after" }, MineInOrder(response));
}
[Test]
public void GetConsole_TailZero_IsCoercedToDefault()
{
Add(LogType.Log, "a");
Add(LogType.Log, "b");
// tail <= 0 must not mean "unlimited"; it falls back to the default and still returns ours.
var response = ConsoleCommand.GetConsole(tail: 0);
Assert.LessOrEqual(response.Returned, ConsoleCommand.DefaultTail);
CollectionAssert.AreEqual(new[] { "a", "b" }, MineInOrder(response));
}
[Test]
public void Console_IsDiscoverableWithExpectedArgs()
{
CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
var command = CommandRegistry.DiscoverCommands().FirstOrDefault(c => c.Name == "console");
Assert.IsNotNull(command, "console should be discoverable");
Assert.IsFalse(command.MainThreadRequired, "console reads the buffer only; no main thread needed");
var paramNames = command.Parameters.Select(p => p.Name).ToList();
CollectionAssert.Contains(paramNames, "tail");
CollectionAssert.Contains(paramNames, "level");
CollectionAssert.Contains(paramNames, "since");
CollectionAssert.Contains(paramNames, "since_session");
}
[Test]
public void GetConsole_ReportsSessionAndCounts()
{
Add(LogType.Log, "plain");
Add(LogType.Error, "broken");
var response = ConsoleCommand.GetConsole();
Assert.IsNotEmpty(response.Session, "A follow client needs the session to know its cursor is still valid");
Assert.AreEqual(ConsoleLogCapture.Buffer.Session, response.Session);
Assert.GreaterOrEqual(response.Counts.Error, 1);
Assert.GreaterOrEqual(response.Counts.Log, 1);
}
[Test]
public void GetConsole_ForeignSession_ReturnsTailAndReset()
{
Add(LogType.Log, "before");
var cursor = ConsoleCommand.GetConsole().Cursor;
Add(LogType.Log, "after");
// The reported symptom: a cursor from an earlier editor session used to filter out every
// entry and read as "nothing new".
var response = ConsoleCommand.GetConsole(since: cursor, since_session: "some-earlier-session");
Assert.IsTrue(response.Reset);
CollectionAssert.AreEqual(new[] { "before", "after" }, MineInOrder(response));
}
[Test]
public void ConsoleStatus_IsDiscoverableAndReturnsNoEntries()
{
CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
var command = CommandRegistry.DiscoverCommands().FirstOrDefault(c => c.Name == "console_status");
Assert.IsNotNull(command, "console_status should be discoverable");
Assert.IsFalse(command.MainThreadRequired, "console_status must stay pollable while the editor is busy");
Add(LogType.Error, "broken");
var response = ConsoleCommand.GetConsoleStatus();
Assert.IsEmpty(response.Entries, "console_status reports counters only");
Assert.GreaterOrEqual(response.Counts.Error, 1);
Assert.AreEqual(ConsoleLogCapture.Buffer.LastSeq, response.Cursor);
}
[Test]
public void ClearConsole_RemovesSeededMarkerButKeepsCursor()
{
Add(LogType.Log, "doomed");
var cursorBefore = ConsoleLogCapture.Buffer.LastSeq;
ConsoleCommand.ClearConsole();
CollectionAssert.IsEmpty(MineInOrder(ConsoleCommand.GetConsole()),
"Cleared entries must not come back");
Assert.AreEqual(cursorBefore, ConsoleLogCapture.Buffer.LastSeq,
"The cursor must survive a clear, so a follow client sees a gap rather than silently skipping it");
}
[Test]
public void ClearConsole_ViaClient_Succeeds()
{
using (var server = new PipelineTestServer())
{
var response = server.Execute("clear_console");
Assert.IsTrue(response.IsSuccess, $"clear_console should succeed: {response.Error}");
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 54c331b153b587c60e89bfbde9bdca1d
@@ -0,0 +1,572 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Newtonsoft.Json;
using NUnit.Framework;
using Unity.Pipeline.Console;
using Unity.Pipeline.Models;
using UnityEngine;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Tests for <see cref="ConsoleLogBuffer"/> — the ring buffer behind the console command.
/// Exercised directly (no Unity log capture) for determinism.
/// </summary>
class ConsoleLogBufferTests
{
static readonly DateTime k_Ts = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
static ConsoleLogBuffer NewBuffer() => new ConsoleLogBuffer();
[Test]
public void Add_AssignsIncreasingSequenceNumbers()
{
var buffer = NewBuffer();
buffer.Add(LogType.Log, "a", null, k_Ts);
buffer.Add(LogType.Log, "b", null, k_Ts);
buffer.Add(LogType.Log, "c", null, k_Ts);
var entries = buffer.Query(-1, 100, ConsoleLogBuffer.SeverityLog).Entries;
CollectionAssert.AreEqual(new[] { "a", "b", "c" }, entries.Select(e => e.Message));
CollectionAssert.AreEqual(new long[] { 1, 2, 3 }, entries.Select(e => e.Seq));
}
[Test]
public void Query_Snapshot_ReturnsOldestFirst()
{
var buffer = NewBuffer();
buffer.Add(LogType.Log, "first", null, k_Ts);
buffer.Add(LogType.Log, "second", null, k_Ts);
var response = buffer.Query(-1, 100, ConsoleLogBuffer.SeverityLog);
Assert.AreEqual(2, response.Returned);
Assert.AreEqual("first", response.Entries[0].Message);
Assert.AreEqual("second", response.Entries[1].Message);
Assert.AreEqual(2, response.Cursor);
Assert.IsFalse(response.Dropped);
}
[Test]
public void Query_LevelThreshold_IsMinimumSeverity()
{
var buffer = NewBuffer();
buffer.Add(LogType.Log, "log", null, k_Ts);
buffer.Add(LogType.Warning, "warn", null, k_Ts);
buffer.Add(LogType.Error, "error", null, k_Ts);
buffer.Add(LogType.Exception, "exception", null, k_Ts);
var warnAndUp = buffer.Query(-1, 100, ConsoleLogBuffer.SeverityWarn).Entries;
CollectionAssert.AreEqual(new[] { "warn", "error", "exception" }, warnAndUp.Select(e => e.Message));
var errorOnly = buffer.Query(-1, 100, ConsoleLogBuffer.SeverityError).Entries;
CollectionAssert.AreEqual(new[] { "error", "exception" }, errorOnly.Select(e => e.Message));
var all = buffer.Query(-1, 100, ConsoleLogBuffer.SeverityLog).Entries;
Assert.AreEqual(4, all.Length);
}
[Test]
public void Query_Tail_ReturnsMostRecentMatches()
{
var buffer = NewBuffer();
for (int i = 0; i < 10; i++)
buffer.Add(LogType.Log, $"m{i}", null, k_Ts);
var entries = buffer.Query(-1, 3, ConsoleLogBuffer.SeverityLog).Entries;
CollectionAssert.AreEqual(new[] { "m7", "m8", "m9" }, entries.Select(e => e.Message));
}
[Test]
public void Query_Tail_AppliesAfterLevelFilter()
{
var buffer = NewBuffer();
buffer.Add(LogType.Error, "e0", null, k_Ts);
buffer.Add(LogType.Log, "l0", null, k_Ts);
buffer.Add(LogType.Error, "e1", null, k_Ts);
buffer.Add(LogType.Log, "l1", null, k_Ts);
buffer.Add(LogType.Error, "e2", null, k_Ts);
// tail=2 over error-only matches => last two errors, not last two of all entries.
var entries = buffer.Query(-1, 2, ConsoleLogBuffer.SeverityError).Entries;
CollectionAssert.AreEqual(new[] { "e1", "e2" }, entries.Select(e => e.Message));
}
[Test]
public void Query_Since_ReturnsOnlyNewerEntries()
{
var buffer = NewBuffer();
buffer.Add(LogType.Log, "a", null, k_Ts); // seq 1
buffer.Add(LogType.Log, "b", null, k_Ts); // seq 2
buffer.Add(LogType.Log, "c", null, k_Ts); // seq 3
var response = buffer.Query(2, 100, ConsoleLogBuffer.SeverityLog);
CollectionAssert.AreEqual(new[] { "c" }, response.Entries.Select(e => e.Message));
Assert.AreEqual(3, response.Cursor);
Assert.IsFalse(response.Dropped);
}
[Test]
public void Query_SinceAtCursor_ReturnsEmptyButReportsCursor()
{
var buffer = NewBuffer();
buffer.Add(LogType.Log, "a", null, k_Ts);
buffer.Add(LogType.Log, "b", null, k_Ts);
var response = buffer.Query(2, 100, ConsoleLogBuffer.SeverityLog);
Assert.AreEqual(0, response.Returned);
Assert.AreEqual(2, response.Cursor, "Cursor must still advance so a follow client resumes correctly");
Assert.IsFalse(response.Dropped);
}
[Test]
public void Query_SinceBeyondCursor_ReportsResetAndReturnsTail()
{
var buffer = NewBuffer();
buffer.Add(LogType.Log, "a", null, k_Ts);
// long.MaxValue also pins the since+1 overflow the drop check would otherwise hit.
var response = buffer.Query(long.MaxValue, 100, ConsoleLogBuffer.SeverityLog);
CollectionAssert.AreEqual(new[] { "a" }, response.Entries.Select(e => e.Message),
"A cursor past the counter must be refused and the tail returned, not silently filter everything out");
Assert.IsTrue(response.Reset);
Assert.IsTrue(response.Dropped, "Reset implies the caller has a gap it cannot reason about");
}
[Test]
public void Query_CursorReflectsLatestEvenWhenFilteredOut()
{
var buffer = NewBuffer();
buffer.Add(LogType.Error, "boom", null, k_Ts); // seq 1
buffer.Add(LogType.Log, "noise", null, k_Ts); // seq 2, filtered out by error level
var response = buffer.Query(1, 100, ConsoleLogBuffer.SeverityError);
Assert.AreEqual(0, response.Returned, "The only new entry is below the level threshold");
Assert.AreEqual(2, response.Cursor, "Cursor advances past the filtered entry to avoid re-scanning it");
}
[Test]
public void Eviction_DropsOldestAndFlagsDropped()
{
var buffer = NewBuffer();
int total = ConsoleLogBuffer.Capacity + 50;
for (int i = 0; i < total; i++)
buffer.Add(LogType.Log, $"m{i}", null, k_Ts);
Assert.AreEqual(ConsoleLogBuffer.Capacity, buffer.Count, "Buffer should be capped at Capacity");
// since=1 points before the evicted window, so the consumer missed entries.
var response = buffer.Query(1, ConsoleLogBuffer.Capacity, ConsoleLogBuffer.SeverityLog);
Assert.IsTrue(response.Dropped, "Querying from an evicted cursor should set Dropped");
// The most recent entry is always present.
var latest = buffer.Query(-1, 1, ConsoleLogBuffer.SeverityLog).Entries.Single();
Assert.AreEqual($"m{total - 1}", latest.Message);
}
[Test]
public void Dropped_FalseWhenSinceWithinRetainedWindow()
{
var buffer = NewBuffer();
for (int i = 0; i < 10; i++)
buffer.Add(LogType.Log, $"m{i}", null, k_Ts);
var response = buffer.Query(5, 100, ConsoleLogBuffer.SeverityLog);
Assert.IsFalse(response.Dropped);
}
[Test]
public void SaveAndLoad_RoundTripsEntriesAndCursor()
{
var path = Path.Combine("Temp", $"pipeline_console_test_{Guid.NewGuid():N}.json");
try
{
var original = NewBuffer();
original.Add(LogType.Log, "log", null, k_Ts);
original.Add(LogType.Warning, "warn", "trace", k_Ts);
original.Add(LogType.Error, "error", null, k_Ts);
original.Save(path);
var restored = NewBuffer();
Assert.IsTrue(restored.Load(path));
var entries = restored.Query(-1, 100, ConsoleLogBuffer.SeverityLog).Entries;
CollectionAssert.AreEqual(new[] { "log", "warn", "error" }, entries.Select(e => e.Message));
Assert.AreEqual(ConsoleLogBuffer.LevelWarn, entries[1].Level, "Severity must survive the round trip");
Assert.AreEqual("trace", entries[1].StackTrace);
// Sequence continues monotonically after a restore.
restored.Add(LogType.Log, "after", null, k_Ts);
Assert.AreEqual(4, restored.Query(3, 100, ConsoleLogBuffer.SeverityLog).Entries.Single().Seq);
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void Load_MissingFile_ReturnsFalseAndLeavesBufferEmpty()
{
var buffer = NewBuffer();
Assert.IsFalse(buffer.Load(Path.Combine("Temp", $"does_not_exist_{Guid.NewGuid():N}.json")));
Assert.AreEqual(0, buffer.Count);
}
[Test]
public void Load_ClampsRestoredEntriesToCapacityKeepingMostRecent()
{
var path = Path.Combine("Temp", $"pipeline_console_oversized_{Guid.NewGuid():N}.json");
try
{
Directory.CreateDirectory(Path.GetDirectoryName(path) ?? "Temp");
var entries = Enumerable.Range(1, ConsoleLogBuffer.Capacity + 5)
.Select(i => new ConsoleLogEntry
{
Seq = i,
TimestampUtc = k_Ts,
Level = ConsoleLogBuffer.LevelLog,
Message = $"m{i}",
StackTrace = string.Empty
})
.ToArray();
File.WriteAllText(path, JsonConvert.SerializeObject(new
{
lastSeq = (long)(ConsoleLogBuffer.Capacity + 5),
entries
}));
var buffer = NewBuffer();
Assert.IsTrue(buffer.Load(path));
Assert.AreEqual(ConsoleLogBuffer.Capacity, buffer.Count);
var restored = buffer.Query(-1, ConsoleLogBuffer.Capacity, ConsoleLogBuffer.SeverityLog).Entries;
Assert.AreEqual("m6", restored.First().Message);
Assert.AreEqual($"m{ConsoleLogBuffer.Capacity + 5}", restored.Last().Message);
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void Clear_EmptiesEntriesButKeepsSequenceMonotonic()
{
var buffer = NewBuffer();
buffer.Add(LogType.Log, "a", null, k_Ts);
buffer.Add(LogType.Log, "b", null, k_Ts);
buffer.Clear();
Assert.AreEqual(0, buffer.Count);
buffer.Add(LogType.Log, "c", null, k_Ts);
Assert.AreEqual(3, buffer.LastSeq, "Sequence must not reset on Clear");
}
[Test]
public void Add_IsThreadSafe()
{
var buffer = NewBuffer();
const int threads = 8;
const int perThread = 500;
var tasks = Enumerable.Range(0, threads).Select(t => Task.Run(() =>
{
for (int i = 0; i < perThread; i++)
buffer.Add(LogType.Log, $"t{t}-{i}", null, k_Ts);
})).ToArray();
Task.WaitAll(tasks);
Assert.AreEqual(threads * perThread, buffer.LastSeq, "Every add must get a unique sequence number");
Assert.AreEqual(ConsoleLogBuffer.Capacity, buffer.Count);
// All retained seqs are unique.
var seqs = buffer.Query(-1, ConsoleLogBuffer.Capacity, ConsoleLogBuffer.SeverityLog)
.Entries.Select(e => e.Seq).ToArray();
Assert.AreEqual(seqs.Length, seqs.Distinct().Count(), "Sequence numbers must be unique");
}
[Test]
public void Query_ForeignSession_ReportsResetAndReturnsTail()
{
var buffer = NewBuffer();
buffer.SetSession("session-a");
buffer.Add(LogType.Log, "a", null, k_Ts);
buffer.Add(LogType.Log, "b", null, k_Ts);
var response = buffer.Query(1, 100, ConsoleLogBuffer.SeverityLog, "session-b");
CollectionAssert.AreEqual(new[] { "a", "b" }, response.Entries.Select(e => e.Message),
"A cursor from another session names different entries, so it must be refused");
Assert.IsTrue(response.Reset);
Assert.AreEqual("session-a", response.Session);
}
[Test]
public void Query_MatchingSession_HonorsCursor()
{
var buffer = NewBuffer();
buffer.SetSession("session-a");
buffer.Add(LogType.Log, "a", null, k_Ts);
buffer.Add(LogType.Log, "b", null, k_Ts);
var response = buffer.Query(1, 100, ConsoleLogBuffer.SeverityLog, "session-a");
CollectionAssert.AreEqual(new[] { "b" }, response.Entries.Select(e => e.Message));
Assert.IsFalse(response.Reset);
}
[Test]
public void Query_ReportsRetainedCountsBeforeFilterAndTail()
{
var buffer = NewBuffer();
buffer.Add(LogType.Log, "l", null, k_Ts);
buffer.Add(LogType.Warning, "w", null, k_Ts);
buffer.Add(LogType.Error, "e0", null, k_Ts);
buffer.Add(LogType.Exception, "e1", null, k_Ts);
var response = buffer.Query(-1, 1, ConsoleLogBuffer.SeverityError);
Assert.AreEqual(1, response.Returned, "tail and level still apply to the entries");
Assert.AreEqual(2, response.Counts.Error, "Counts describe what the buffer holds, not what was returned");
Assert.AreEqual(1, response.Counts.Warn);
Assert.AreEqual(1, response.Counts.Log);
}
[Test]
public void Stats_ReportsCountsAndCursorWithoutEntries()
{
var buffer = NewBuffer();
buffer.SetSession("session-a");
buffer.Add(LogType.Log, "l", null, k_Ts);
buffer.Add(LogType.Error, "e", null, k_Ts);
var stats = buffer.Stats();
Assert.IsEmpty(stats.Entries);
Assert.AreEqual(2, stats.Cursor);
Assert.AreEqual("session-a", stats.Session);
Assert.AreEqual(1, stats.Counts.Error);
Assert.AreEqual(1, stats.Counts.Log);
}
[Test]
public void Add_Seeded_MarksEntryAndKeepsLogType()
{
var buffer = NewBuffer();
buffer.Add(LogType.Exception, "threw", null, k_Ts, seeded: true);
buffer.Add(LogType.Log, "live", null, k_Ts);
var entries = buffer.Query(-1, 100, ConsoleLogBuffer.SeverityLog).Entries;
Assert.IsTrue(entries[0].Seeded);
Assert.AreEqual("Exception", entries[0].LogType, "The exact log type survives the severity collapse");
Assert.AreEqual(ConsoleLogBuffer.LevelError, entries[0].Level);
Assert.IsFalse(entries[1].Seeded);
}
[Test]
public void Load_KeepsEntriesLoggedSinceSnapshot()
{
var path = Path.Combine("Temp", $"pipeline_console_merge_{Guid.NewGuid():N}.json");
try
{
// The [NoAutoStaticsCleanup] case: the buffer survives the reload, so entries logged
// after the snapshot are still in memory when the restore runs and must not be lost.
var buffer = NewBuffer();
buffer.Add(LogType.Log, "a", null, k_Ts);
buffer.Add(LogType.Log, "b", null, k_Ts);
buffer.Save(path);
buffer.Add(LogType.Log, "c", null, k_Ts);
buffer.Add(LogType.Log, "d", null, k_Ts);
Assert.IsTrue(buffer.Load(path));
var entries = buffer.Query(-1, 100, ConsoleLogBuffer.SeverityLog).Entries;
CollectionAssert.AreEqual(new[] { "a", "b", "c", "d" }, entries.Select(e => e.Message));
Assert.AreEqual(4, buffer.LastSeq);
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void Load_NeverLowersLastSeq()
{
var path = Path.Combine("Temp", $"pipeline_console_stale_{Guid.NewGuid():N}.json");
try
{
Directory.CreateDirectory("Temp");
File.WriteAllText(path, JsonConvert.SerializeObject(new
{
lastSeq = 5L,
entries = Array.Empty<ConsoleLogEntry>()
}));
var buffer = NewBuffer();
for (int i = 0; i < 100; i++)
buffer.Add(LogType.Log, $"m{i}", null, k_Ts);
Assert.IsTrue(buffer.Load(path));
Assert.AreEqual(100, buffer.LastSeq, "A stale snapshot must not rewind cursors already handed out");
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void RaiseLastSeq_RaisesButNeverLowers()
{
var buffer = NewBuffer();
buffer.Add(LogType.Log, "a", null, k_Ts);
buffer.RaiseLastSeq(50);
Assert.AreEqual(50, buffer.LastSeq);
buffer.RaiseLastSeq(10);
Assert.AreEqual(50, buffer.LastSeq);
buffer.Add(LogType.Log, "b", null, k_Ts);
Assert.AreEqual(51, buffer.Query(50, 100, ConsoleLogBuffer.SeverityLog).Entries.Single().Seq);
}
// Two shapes a domain reload takes: statics wiped, so the restore lands in a fresh buffer;
// or statics kept ([NoAutoStaticsCleanup]), so the buffer that survives already holds the
// entries logged after the snapshot was written. The second is where drift compounds.
[TestCase(false, TestName = "RepeatedReloadCycles_LoseNoEntriesAndKeepSeqMonotonic(StaticsWiped)")]
[TestCase(true, TestName = "RepeatedReloadCycles_LoseNoEntriesAndKeepSeqMonotonic(StaticsSurvive)")]
public void RepeatedReloadCycles_LoseNoEntriesAndKeepSeqMonotonic(bool staticsSurvive)
{
// The agentic loop drives a domain reload per recompile, and the reported corruption was
// drift that only showed after several. One cycle proves nothing; this walks twenty,
// logging on both sides of every save.
var path = Path.Combine("Temp", $"pipeline_console_cycles_{Guid.NewGuid():N}.json");
try
{
var buffer = NewBuffer();
var expected = new List<string>();
var cursors = new List<long>();
for (var cycle = 0; cycle < 20; cycle++)
{
var before = $"c{cycle}-before";
buffer.Add(LogType.Log, before, null, k_Ts);
expected.Add(before);
buffer.Save(path);
// Logged after the snapshot: only a surviving buffer still holds this when the
// restore runs, and the restore must not discard it.
var between = $"c{cycle}-between";
buffer.Add(LogType.Log, between, null, k_Ts);
expected.Add(between);
if (!staticsSurvive)
{
// A wiped domain loses the entry logged after the save, exactly as the real
// one does — it was never persisted.
expected.Remove(between);
buffer = NewBuffer();
}
buffer.Load(path);
var after = $"c{cycle}-after";
buffer.Add(LogType.Log, after, null, k_Ts);
expected.Add(after);
cursors.Add(buffer.LastSeq);
}
var entries = buffer.Query(-1, 0, ConsoleLogBuffer.SeverityLog).Entries;
CollectionAssert.AreEqual(expected, entries.Select(e => e.Message),
"No cycle may drop or duplicate an entry");
var seqs = entries.Select(e => e.Seq).ToArray();
CollectionAssert.AreEqual(seqs.OrderBy(x => x), seqs, "Sequence numbers must stay ordered");
Assert.AreEqual(seqs.Length, seqs.Distinct().Count(), "Sequence numbers must stay unique");
CollectionAssert.AreEqual(cursors.OrderBy(x => x), cursors,
"The cursor must never go backwards across a reload");
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void EditorRestart_RefusesACursorFromTheSessionBefore()
{
// The likeliest real-world trigger: Unity deletes Temp/ at every editor launch, so the
// snapshot is gone and the counter restarts at zero while a client still holds a cursor
// from the session before. That cursor is far ahead of the new counter, so it used to
// filter out every entry and read as "nothing new" indefinitely.
var path = Path.Combine("Temp", $"pipeline_console_restart_{Guid.NewGuid():N}.json");
try
{
var firstSession = NewBuffer();
firstSession.SetSession("session-before-restart");
for (var i = 0; i < 500; i++)
firstSession.Add(LogType.Log, $"old{i}", null, k_Ts);
firstSession.Save(path);
var clientCursor = firstSession.LastSeq;
var clientSession = firstSession.Session;
Assert.AreEqual(500, clientCursor);
// The restart: Temp/ is wiped, so there is nothing to restore, and the new session
// gets a new identifier and a counter starting from zero.
File.Delete(path);
var afterRestart = NewBuffer();
afterRestart.SetSession("session-after-restart");
Assert.IsFalse(afterRestart.Load(path), "The snapshot is gone with Temp/");
Assert.AreEqual(0, afterRestart.LastSeq);
afterRestart.Add(LogType.Error, "compile error after restart", null, k_Ts);
var response = afterRestart.Query(clientCursor, 100, ConsoleLogBuffer.SeverityLog, clientSession);
CollectionAssert.AreEqual(new[] { "compile error after restart" },
response.Entries.Select(e => e.Message),
"The stale cursor must be refused and the entry served, not silently filtered out");
Assert.IsTrue(response.Reset);
Assert.IsTrue(response.Dropped);
Assert.AreEqual("session-after-restart", response.Session,
"The response must hand back the session the client should adopt");
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void SeverityFromLevelName_ParsesNamesAndAliases()
{
Assert.AreEqual(ConsoleLogBuffer.SeverityError, ConsoleLogBuffer.SeverityFromLevelName("error"));
Assert.AreEqual(ConsoleLogBuffer.SeverityError, ConsoleLogBuffer.SeverityFromLevelName("ERR"));
Assert.AreEqual(ConsoleLogBuffer.SeverityWarn, ConsoleLogBuffer.SeverityFromLevelName("warn"));
Assert.AreEqual(ConsoleLogBuffer.SeverityWarn, ConsoleLogBuffer.SeverityFromLevelName("Warning"));
Assert.AreEqual(ConsoleLogBuffer.SeverityLog, ConsoleLogBuffer.SeverityFromLevelName("log"));
Assert.AreEqual(ConsoleLogBuffer.SeverityLog, ConsoleLogBuffer.SeverityFromLevelName("nonsense"));
Assert.AreEqual(ConsoleLogBuffer.SeverityLog, ConsoleLogBuffer.SeverityFromLevelName(null));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 524e94027ab20453a5442be5e73f87a1
@@ -0,0 +1,79 @@
using System.Threading.Tasks;
using NUnit.Framework;
using Newtonsoft.Json.Linq;
using Unity.Pipeline;
using Unity.Pipeline.Editor;
using Unity.Pipeline.Security;
namespace Unity.Pipeline.Tests.Editor
{
class DialogEndpointTests
{
sealed class DialogTestServer : EditorPipelineServer
{
protected override bool WritesDescriptor => false;
protected override (int basePort, int maxPort) GetPortRange() => (7850, 7899);
protected override string GetToken() => SecurityTokenManager.GetOrCreateToken();
protected override bool IsSettled => true;
}
DialogTestServer m_Server;
Unity.Pipeline.Tests.Runtime.PipelineClient m_Client;
[SetUp]
public void SetUp()
{
m_Server = new DialogTestServer();
m_Server.Start();
m_Client = new Unity.Pipeline.Tests.Runtime.PipelineClient(m_Server);
}
[TearDown]
public void TearDown()
{
m_Client?.Dispose();
m_Server?.Stop();
}
[Test]
public async Task ApiDialog_NoDialogOpen_ReportsInactiveEmptyList()
{
var http = await m_Client.GetHttpAsync("/api/dialog");
var json = JObject.Parse(await http.Content.ReadAsStringAsync());
Assert.IsFalse(json["active"].ToObject<bool>());
Assert.IsEmpty((JArray)json["dialogs"]);
}
[Test]
public async Task ApiDialog_OneDialogOpen_ReportsItsFields()
{
m_Server.Dialogs.OnShown(7, "native", "Save changes?", "You have unsaved changes.",
new[] { "Save", "Cancel" }, "Warning", System.DateTime.UtcNow);
var http = await m_Client.GetHttpAsync("/api/dialog");
var json = JObject.Parse(await http.Content.ReadAsStringAsync());
Assert.IsTrue(json["active"].ToObject<bool>());
var dialog = (JObject)((JArray)json["dialogs"])[0];
Assert.AreEqual(7, dialog["id"].ToObject<int>());
Assert.AreEqual("native", dialog["source"].ToString());
Assert.AreEqual("Save changes?", dialog["title"].ToString());
Assert.AreEqual("Warning", dialog["level"].ToString());
CollectionAssert.AreEqual(new[] { "Save", "Cancel" }, dialog["buttons"].ToObject<string[]>());
}
[Test]
public async Task ApiDialog_DismissedDialog_NoLongerReportedAsActive()
{
m_Server.Dialogs.OnShown(7, "native", "Title", "Message", new[] { "OK" }, "Info", System.DateTime.UtcNow);
m_Server.Dialogs.OnDismissed(7, System.DateTime.UtcNow);
var http = await m_Client.GetHttpAsync("/api/dialog");
var json = JObject.Parse(await http.Content.ReadAsStringAsync());
Assert.IsFalse(json["active"].ToObject<bool>());
Assert.IsEmpty((JArray)json["dialogs"]);
}
}
}
@@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: 46890cbfc14844928e0d9f5af8a0a6af
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetVersion: 1
@@ -0,0 +1,118 @@
using System;
using System.Linq;
using NUnit.Framework;
using Unity.Pipeline;
namespace Unity.Pipeline.Tests.Editor
{
class DialogStateTrackerTests
{
[Test]
public void NoDialogShown_IsDialogOpen_False_AndCurrentlyOpen_Empty()
{
var tracker = new DialogStateTracker();
Assert.IsFalse(tracker.IsDialogOpen);
Assert.IsEmpty(tracker.CurrentlyOpen);
}
[Test]
public void OnShown_MakesIsDialogOpen_True_AndAppearsInCurrentlyOpen()
{
var tracker = new DialogStateTracker();
var openedAt = DateTime.UtcNow;
tracker.OnShown(1, "native", "Title", "Message", new[] { "OK" }, "Warning", openedAt);
Assert.IsTrue(tracker.IsDialogOpen);
var open = tracker.CurrentlyOpen.Single();
Assert.AreEqual(1, open.Id);
Assert.AreEqual("native", open.Source);
Assert.AreEqual("Title", open.Title);
Assert.AreEqual("Message", open.Message);
CollectionAssert.AreEqual(new[] { "OK" }, open.Buttons);
Assert.AreEqual("Warning", open.Level);
Assert.AreEqual(openedAt, open.OpenedAtUtc);
}
[Test]
public void OnDismissed_RemovesFromCurrentlyOpen()
{
var tracker = new DialogStateTracker();
tracker.OnShown(1, "native", "Title", "Message", new[] { "OK" }, "Info", DateTime.UtcNow);
tracker.OnDismissed(1, DateTime.UtcNow);
Assert.IsFalse(tracker.IsDialogOpen);
Assert.IsEmpty(tracker.CurrentlyOpen);
}
[Test]
public void TwoDialogsOpenBeforeEitherDismissed_BothAppearInCurrentlyOpen_DismissingOneLeavesOtherActive()
{
var tracker = new DialogStateTracker();
tracker.OnShown(1, "native", "A", "a", new[] { "OK" }, "Info", DateTime.UtcNow);
tracker.OnShown(2, "managed", "B", null, null, null, DateTime.UtcNow);
Assert.AreEqual(2, tracker.CurrentlyOpen.Count);
tracker.OnDismissed(1, DateTime.UtcNow);
Assert.IsTrue(tracker.IsDialogOpen);
var remaining = tracker.CurrentlyOpen.Single();
Assert.AreEqual(2, remaining.Id);
}
[Test]
public void OnDismissed_WithUnknownId_IsNoOp()
{
var tracker = new DialogStateTracker();
Assert.DoesNotThrow(() => tracker.OnDismissed(999, DateTime.UtcNow));
Assert.IsFalse(tracker.IsDialogOpen);
}
[Test]
public void EventsSince_IncludesDismissedDialogsOpenedAtOrAfterTimestamp()
{
var tracker = new DialogStateTracker();
var beforeCommand = DateTime.UtcNow;
var openedAt = beforeCommand.AddMilliseconds(10);
tracker.OnShown(1, "native", "Title", "Message", new[] { "OK" }, "Info", openedAt);
tracker.OnDismissed(1, openedAt.AddMilliseconds(5));
var events = tracker.EventsSince(beforeCommand);
var found = events.Single();
Assert.AreEqual(1, found.Id);
Assert.IsTrue(found.DismissedAtUtc.HasValue);
}
[Test]
public void EventsSince_ExcludesDialogsOpenedBeforeTheTimestamp()
{
var tracker = new DialogStateTracker();
var openedAt = DateTime.UtcNow;
tracker.OnShown(1, "native", "Title", "Message", new[] { "OK" }, "Info", openedAt);
tracker.OnDismissed(1, openedAt.AddMilliseconds(5));
var events = tracker.EventsSince(openedAt.AddSeconds(1));
Assert.IsEmpty(events);
}
[Test]
public void EventsSince_IncludesStillOpenDialogs()
{
var tracker = new DialogStateTracker();
var beforeCommand = DateTime.UtcNow;
tracker.OnShown(1, "native", "Title", "Message", new[] { "OK" }, "Info", beforeCommand.AddMilliseconds(5));
var events = tracker.EventsSince(beforeCommand);
var found = events.Single();
Assert.AreEqual(1, found.Id);
Assert.IsFalse(found.DismissedAtUtc.HasValue);
}
}
}
@@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: 7b42c8c2fdf4495c9b86bbfbec8f2a9f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetVersion: 1
@@ -0,0 +1,95 @@
using System;
using System.Threading.Tasks;
using NUnit.Framework;
using Newtonsoft.Json.Linq;
using Unity.Pipeline;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor;
using Unity.Pipeline.Security;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Test-only command that raises a dialog shown+dismissed pair on a caller-supplied tracker
/// from inside its own handler — lets DialogsDuringExecutionTests exercise the real
/// execStartUtc-scoped attach logic without needing a real modal dialog.
/// </summary>
static class SimulateDialogDuringExecCommand
{
internal static DialogStateTracker TargetTracker;
[CliCommand("test_simulate_dialog_during_exec", "Test-only: raises a dialog shown+dismissed pair during its own execution", MainThreadRequired = true)]
public static bool Run()
{
TargetTracker?.OnShown(42, "native", "Mid-command dialog", "msg", new[] { "OK" }, "Info", DateTime.UtcNow);
TargetTracker?.OnDismissed(42, DateTime.UtcNow);
return true;
}
}
class DialogsDuringExecutionTests
{
sealed class DialogTestServer : EditorPipelineServer
{
protected override bool WritesDescriptor => false;
protected override (int basePort, int maxPort) GetPortRange() => (7850, 7899);
protected override string GetToken() => SecurityTokenManager.GetOrCreateToken();
protected override bool IsSettled => true;
}
DialogTestServer m_Server;
Unity.Pipeline.Tests.Runtime.PipelineClient m_Client;
[SetUp]
public void SetUp()
{
CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
m_Server = new DialogTestServer();
m_Server.Start();
m_Client = new Unity.Pipeline.Tests.Runtime.PipelineClient(m_Server);
SimulateDialogDuringExecCommand.TargetTracker = m_Server.Dialogs;
}
[TearDown]
public void TearDown()
{
SimulateDialogDuringExecCommand.TargetTracker = null;
m_Client?.Dispose();
m_Server?.Stop();
}
[Test]
public async Task ApiExec_NoDialogRaised_OmitsDialogsDuringExecutionField()
{
var response = await m_Client.ExecuteCommandAsync("editor_status", new { });
Assert.IsTrue(response.IsSuccess);
Assert.IsNull(response.JsonResponse["dialogsDuringExecution"]);
}
[Test]
public async Task ApiExec_DialogRaisedAndDismissedInsideHandler_AttachesIt()
{
var response = await m_Client.ExecuteCommandAsync("test_simulate_dialog_during_exec", new { });
Assert.IsTrue(response.IsSuccess);
var attached = (JArray)response.JsonResponse["dialogsDuringExecution"];
Assert.IsNotNull(attached, "A dialog raised inside the command's own handler must be attached");
Assert.AreEqual(42, attached[0]["id"].ToObject<int>());
}
[Test]
public async Task ApiExec_DialogRaisedLongBeforeCommand_DoesNotLeakIntoResponse()
{
m_Server.Dialogs.OnShown(1, "native", "Old dialog", "msg", new[] { "OK" }, "Info",
System.DateTime.UtcNow.AddMinutes(-5));
m_Server.Dialogs.OnDismissed(1, System.DateTime.UtcNow.AddMinutes(-5));
var response = await m_Client.ExecuteCommandAsync("editor_status", new { });
Assert.IsTrue(response.IsSuccess);
Assert.IsNull(response.JsonResponse["dialogsDuringExecution"],
"A dialog that opened well before this call started must not leak into its response");
}
}
}
@@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: 1c0002bd3c824e128c59d61d2739fb41
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetVersion: 1

Some files were not shown because too many files have changed in this diff Show More