Add Unity Pipeline package support
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: de3774b0893944d8ac6871cbcaa631ab
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+211
@@ -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>
|
||||
public class AnimationClipCommandsTests
|
||||
{
|
||||
private const string Root = "Assets/__CLI214AnimTest";
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
ProjectPaths.ResetAuthoringRoot();
|
||||
if (AssetDatabase.IsValidFolder(Root))
|
||||
{
|
||||
AssetDatabase.DeleteAsset(Root);
|
||||
AssetDatabase.Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 613a4bb343db4a4cb96ae6fef8195425
|
||||
+261
@@ -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>
|
||||
public class AnimatorControllerCommandsTests
|
||||
{
|
||||
private const string Root = "Assets/__CLI214CtrlTest";
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
ProjectPaths.ResetAuthoringRoot();
|
||||
if (AssetDatabase.IsValidFolder(Root))
|
||||
{
|
||||
AssetDatabase.DeleteAsset(Root);
|
||||
AssetDatabase.Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c1d78b8f19fc48cea840bc62c3204b3e
|
||||
+81
@@ -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>
|
||||
public class TimelineCommandsTests
|
||||
{
|
||||
private const string Root = "Assets/__CLI214TimelineTest";
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
ProjectPaths.ResetAuthoringRoot();
|
||||
if (AssetDatabase.IsValidFolder(Root))
|
||||
{
|
||||
AssetDatabase.DeleteAsset(Root);
|
||||
AssetDatabase.Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 72d8c657533f4b8b96feb3af780d619a
|
||||
@@ -0,0 +1,259 @@
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Editor;
|
||||
using Unity.Pipeline.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEngine.TestTools;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for HTTP API endpoints that CLI tools will consume.
|
||||
/// These test the complete server API surface for remote command execution.
|
||||
/// </summary>
|
||||
public class ApiEndpointsTests
|
||||
{
|
||||
private EditorPipelineServer m_Server;
|
||||
private Unity.Pipeline.Tests.Runtime.PipelineClient m_PipelineClient;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
// Setup command discovery for tests
|
||||
CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
|
||||
|
||||
// Start an ISOLATED test server (ports 7850-7899, writes no descriptor) for endpoint
|
||||
// testing, so we never bind the live server's port (7800) or clobber its descriptor.
|
||||
m_Server = new TestEditorPipelineServer();
|
||||
m_Server.Start();
|
||||
|
||||
m_PipelineClient = new Unity.Pipeline.Tests.Runtime.PipelineClient(m_Server);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
m_PipelineClient?.Dispose();
|
||||
m_Server?.Stop();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ApiCommands_GetEndpoint_ReturnsCommandList()
|
||||
{
|
||||
// Act - Call /api/commands endpoint using unified Pipeline client
|
||||
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands");
|
||||
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
|
||||
|
||||
// Assert - Response structure
|
||||
Assert.IsTrue(httpResponse.IsSuccessStatusCode,
|
||||
$"Commands endpoint should return success, got: {httpResponse.StatusCode}");
|
||||
Assert.AreEqual("application/json", httpResponse.Content.Headers.ContentType.MediaType,
|
||||
"Commands endpoint should return JSON content type");
|
||||
|
||||
// Assert - JSON parsing
|
||||
var responseJson = JObject.Parse(jsonContent);
|
||||
Assert.IsNotNull(responseJson, "Should be able to parse commands JSON");
|
||||
|
||||
// Verify response structure
|
||||
Assert.IsNotNull(responseJson["commands"], "Response should have commands array");
|
||||
Assert.IsNotNull(responseJson["count"], "Response should have count field");
|
||||
Assert.IsNotNull(responseJson["server"], "Response should have server info");
|
||||
|
||||
// Verify commands array contains discovered commands
|
||||
var commands = responseJson["commands"] as JArray;
|
||||
Assert.Greater(commands.Count, 0, "Should have at least one discovered command");
|
||||
|
||||
// Verify a specific test command is included
|
||||
var testCommand = commands.Cast<JObject>()
|
||||
.FirstOrDefault(cmd => cmd["name"]?.ToString() == "log_editor");
|
||||
Assert.IsNotNull(testCommand, "Should include log_editor test command");
|
||||
Assert.AreEqual("Log a message to Unity Editor console", testCommand["description"]?.ToString());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ApiCommands_OnEditorServer_ExcludesRuntimeOnlyCommands()
|
||||
{
|
||||
// Act - List commands from the Editor server
|
||||
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands");
|
||||
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
|
||||
var responseJson = JObject.Parse(jsonContent);
|
||||
var commandNames = (responseJson["commands"] as JArray)
|
||||
.Cast<JObject>()
|
||||
.Select(cmd => cmd["name"]?.ToString())
|
||||
.ToList();
|
||||
|
||||
// Assert - editor commands are listed, runtime-only commands are hidden
|
||||
Assert.Contains("editor_status", commandNames, "Editor command should be listed");
|
||||
CollectionAssert.DoesNotContain(commandNames, "runtime_status", "Runtime-only eval should be hidden on the Editor server");
|
||||
CollectionAssert.DoesNotContain(commandNames, "set_target_framerate", "Runtime-only reload_file_override should be hidden on the Editor server");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ApiCommands_CommandStructure_ContainsRequiredFields()
|
||||
{
|
||||
// Act - Call /api/commands endpoint using unified Pipeline client
|
||||
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands");
|
||||
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
|
||||
var responseJson = JObject.Parse(jsonContent);
|
||||
|
||||
// Assert - Command structure validation
|
||||
var commands = responseJson["commands"] as JArray;
|
||||
var firstCommand = commands[0] as JObject;
|
||||
|
||||
// Required command fields
|
||||
Assert.IsNotNull(firstCommand["name"], "Command should have name field");
|
||||
Assert.IsNotNull(firstCommand["description"], "Command should have description field");
|
||||
Assert.IsNotNull(firstCommand["parameters"], "Command should have parameters array");
|
||||
Assert.IsNotNull(firstCommand["schema"], "Command should have JSON schema");
|
||||
Assert.IsNotNull(firstCommand["mainThreadRequired"], "Command should have mainThreadRequired field");
|
||||
|
||||
// Verify schema is valid JSON
|
||||
var schema = firstCommand["schema"]?.ToString();
|
||||
var schemaJson = JObject.Parse(schema);
|
||||
Assert.AreEqual(firstCommand["name"]?.ToString(), schemaJson["title"]?.ToString(),
|
||||
"Schema title should match command name");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ApiStatus_GetBasicStatus_ReturnsServerInfo()
|
||||
{
|
||||
// Act - Call basic /api/status endpoint using unified Pipeline client
|
||||
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/status");
|
||||
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
|
||||
|
||||
// Assert - Response structure
|
||||
Assert.IsTrue(httpResponse.IsSuccessStatusCode,
|
||||
$"Basic status endpoint should return success, got: {httpResponse.StatusCode}");
|
||||
Assert.AreEqual("application/json", httpResponse.Content.Headers.ContentType.MediaType,
|
||||
"Basic status endpoint should return JSON content type");
|
||||
|
||||
// Assert - JSON structure (basic server info only, no Editor APIs)
|
||||
var statusJson = JObject.Parse(jsonContent);
|
||||
Assert.IsNotNull(statusJson["status"], "Should have status field");
|
||||
|
||||
// Verify basic values
|
||||
Assert.AreEqual("ready", statusJson["status"]?.ToString());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ApiEditorStatus_GetDetailedStatus_ReturnsEditorInfo()
|
||||
{
|
||||
// Act - Call rich /api/editor_status endpoint using unified Pipeline client
|
||||
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/editor_status");
|
||||
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
|
||||
|
||||
// Assert - Response structure
|
||||
Assert.IsTrue(httpResponse.IsSuccessStatusCode,
|
||||
$"Editor status endpoint should return success, got: {httpResponse.StatusCode}. Response: {jsonContent}");
|
||||
Assert.AreEqual("application/json", httpResponse.Content.Headers.ContentType.MediaType,
|
||||
"Editor status endpoint should return JSON content type");
|
||||
|
||||
// Assert - Rich Editor status structure
|
||||
var statusJson = JObject.Parse(jsonContent);
|
||||
Assert.IsNotNull(statusJson["status"], "Should have overall status");
|
||||
Assert.IsNotNull(statusJson["compiling"], "Should have compiling state");
|
||||
Assert.IsNotNull(statusJson["domainReloadInProgress"], "Should have domain reload state");
|
||||
Assert.IsNotNull(statusJson["playMode"], "Should have play mode state");
|
||||
Assert.IsNotNull(statusJson["unityVersion"], "Should have Unity version");
|
||||
|
||||
// Verify Editor-specific data is present
|
||||
Assert.Contains(statusJson["status"]?.ToString(), new[] { "ready", "compiling", "playing", "reloading" });
|
||||
Assert.Contains(statusJson["playMode"]?.ToString(), new[] { "stopped", "playing", "paused" });
|
||||
Assert.IsInstanceOf<bool>(statusJson["compiling"]?.ToObject<bool>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ApiExec_PostCommand_ExecutesSuccessfully()
|
||||
{
|
||||
// Arrange
|
||||
var commandRequest = new CommandExecutionRequest("log_editor");
|
||||
commandRequest.Parameters["message"] = "Test message from CLI";
|
||||
|
||||
// Act - Execute command via /api/exec endpoint using unified Pipeline client
|
||||
var response = await m_PipelineClient.PostJsonAsync("/api/exec", commandRequest);
|
||||
var responseContent = response.RawResponse;
|
||||
|
||||
// Assert - Response structure
|
||||
Assert.IsTrue(response.IsSuccess,
|
||||
$"Exec endpoint should return success, got: {response.StatusCode}. Response: {responseContent}");
|
||||
|
||||
// Assert - JSON parsing and structure
|
||||
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
|
||||
var responseJson = response.JsonResponse;
|
||||
Assert.IsNotNull(responseJson["success"], "Response should have success field");
|
||||
Assert.IsNotNull(responseJson["command"], "Response should have command field");
|
||||
Assert.IsNotNull(responseJson["executedAt"], "Response should have executedAt timestamp");
|
||||
|
||||
// Assert - Successful execution
|
||||
Assert.IsTrue(responseJson["success"].ToObject<bool>(), "Command should execute successfully");
|
||||
Assert.AreEqual("log_editor", responseJson["command"]?.ToString());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ApiExec_InvalidCommand_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var invalidRequest = new CommandExecutionRequest("nonexistent_command");
|
||||
|
||||
// Act - Execute invalid command via /api/exec endpoint using unified Pipeline client
|
||||
var response = await m_PipelineClient.PostJsonAsync("/api/exec", invalidRequest);
|
||||
var responseContent = response.RawResponse;
|
||||
|
||||
LogAssert.Expect(new Regex("^ExecuteCommandByName: No command named"));
|
||||
|
||||
// Assert - Should return error
|
||||
Assert.IsFalse(response.IsSuccess, "Should return error for invalid command");
|
||||
|
||||
Assert.IsTrue(response.HasValidJson, "Error response should have valid JSON");
|
||||
var responseJson = response.JsonResponse;
|
||||
Assert.IsNotNull(responseJson["error"], "Error response should have error field");
|
||||
Assert.IsNotNull(responseJson["message"], "Error response should have message field");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ApiExec_MissingRequiredParameter_ReturnsValidationError()
|
||||
{
|
||||
// Arrange - Try to execute log_editor without required message parameter
|
||||
var invalidRequest = new CommandExecutionRequest("log_editor");
|
||||
// Intentionally not setting the 'message' parameter to test validation
|
||||
|
||||
// Act - Execute command with missing parameter via /api/exec endpoint using unified Pipeline client
|
||||
var response = await m_PipelineClient.PostJsonAsync("/api/exec", invalidRequest);
|
||||
var responseContent = response.RawResponse;
|
||||
|
||||
LogAssert.Expect("ExecuteCommandByName: Parameter validation failed: Required parameter 'message' is missing or empty");
|
||||
|
||||
// Assert - Should return validation error
|
||||
Assert.IsFalse(response.IsSuccess, "Should return error for missing required parameter");
|
||||
|
||||
Assert.IsTrue(response.HasValidJson, "Error response should have valid JSON");
|
||||
var responseJson = response.JsonResponse;
|
||||
Assert.IsNotNull(responseJson["error"], "Should have error field");
|
||||
Assert.That(responseJson["errorDetails"]?.ToString(),
|
||||
Contains.Substring("message").IgnoreCase,
|
||||
"Error should mention missing message parameter");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ApiExec_OversizedBody_ReturnsPayloadTooLarge()
|
||||
{
|
||||
// Arrange - a request whose body exceeds the 1 MiB cap (Content-Length will advertise it).
|
||||
var oversized = new CommandExecutionRequest("log_editor");
|
||||
oversized.Parameters["message"] = new string('a', (1 * 1024 * 1024) + 1024);
|
||||
|
||||
// Act
|
||||
var response = await m_PipelineClient.PostJsonAsync("/api/exec", oversized);
|
||||
|
||||
// Assert - rejected with 413 before the command ever runs.
|
||||
Assert.AreEqual(413, response.StatusCode,
|
||||
$"Oversized body should be rejected with 413. Response: {response.RawResponse}");
|
||||
Assert.IsTrue(response.HasValidJson, "413 response should have valid JSON");
|
||||
Assert.That(response.JsonResponse["error"]?.ToString(),
|
||||
Contains.Substring("Payload Too Large"),
|
||||
"413 response should identify the payload-too-large error");
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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:
|
||||
+246
@@ -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>
|
||||
public 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>
|
||||
public class AssetCommandsTests
|
||||
{
|
||||
private const string Root = "Assets/__CLI191Test";
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
ProjectPaths.ResetAuthoringRoot();
|
||||
if (AssetDatabase.IsValidFolder(Root))
|
||||
{
|
||||
AssetDatabase.DeleteAsset(Root);
|
||||
AssetDatabase.Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d4736a7747a847edb2355c95559ce0b0
|
||||
+103
@@ -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>
|
||||
public class AssetImportCommandsTests
|
||||
{
|
||||
private const string Root = "Assets/__CLI191ImportTest";
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
ProjectPaths.ResetAuthoringRoot();
|
||||
if (AssetDatabase.IsValidFolder(Root))
|
||||
{
|
||||
AssetDatabase.DeleteAsset(Root);
|
||||
AssetDatabase.Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 121fa6671cf449ce863c389aa9b7e18c
|
||||
+432
@@ -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>
|
||||
public class ImportSettingsCommandTests
|
||||
{
|
||||
private const string Root = "Assets/__CLI212ImportTest";
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
ProjectPaths.ResetAuthoringRoot();
|
||||
if (AssetDatabase.IsValidFolder(Root))
|
||||
{
|
||||
AssetDatabase.DeleteAsset(Root);
|
||||
AssetDatabase.Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 48fae49d74ce4b61941f9c5ff541034f
|
||||
+182
@@ -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>
|
||||
public 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <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
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7d514449899cc0f439d287ab64433a22
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
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 PipelinePhysicsMaterial = UnityEngine.PhysicsMaterial;
|
||||
#else
|
||||
using PipelinePhysicsMaterial = 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>
|
||||
public 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();
|
||||
}
|
||||
}
|
||||
|
||||
private static void AssertDefaults(PipelinePhysicsMaterial 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<PipelinePhysicsMaterial>(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<PipelinePhysicsMaterial>(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<PipelinePhysicsMaterial>(loaded, "Legacy 'PhysicMaterial' type name should resolve to PhysicsMaterial");
|
||||
AssertDefaults(loaded as PipelinePhysicsMaterial);
|
||||
}
|
||||
|
||||
[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<PipelinePhysicsMaterial>(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<PipelinePhysicsMaterial>(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<PipelinePhysicsMaterial>(AssetDatabase.LoadMainAssetAtPath(created),
|
||||
"Legacy 'PhysicMaterial' should resolve to PhysicsMaterial over the wire");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: af05410e747b85c47ab38e0bf2db7fd8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+108
@@ -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>
|
||||
public class TextFileCommandsTests
|
||||
{
|
||||
private const string Root = "Assets/__CLI191TextTest";
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
ProjectPaths.ResetAuthoringRoot();
|
||||
if (AssetDatabase.IsValidFolder(Root))
|
||||
{
|
||||
AssetDatabase.DeleteAsset(Root);
|
||||
AssetDatabase.Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
#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
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4ede31badcf640f6acc3c5baf0fc8ef3
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 497149ab4929cd34483ff611ce25534a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+85
@@ -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>
|
||||
public 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();
|
||||
}
|
||||
}
|
||||
|
||||
#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
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4653539f2cf4a1b4699bd2c7a8d35188
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+223
@@ -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>
|
||||
public 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
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b218af7c5c4799a4ab37d7a670a6256e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+145
@@ -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>
|
||||
public 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e864b00059a57364d902089021b33742
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+84
@@ -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>
|
||||
public 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9af4698d059fd9d43b72c6e1dad56424
|
||||
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:
|
||||
+199
@@ -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>
|
||||
public 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bdeb4ada982746e1aa5d72b49157fd3e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+125
@@ -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>
|
||||
public 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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d88fed4842cc4242b9e25237547c5514
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+69
@@ -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>
|
||||
public 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"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f65c8110a95f44cbbf3aaf0d9832aa0e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+105
@@ -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>
|
||||
public 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);
|
||||
}
|
||||
}
|
||||
+11
@@ -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:
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
using System;
|
||||
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;
|
||||
|
||||
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>
|
||||
public 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 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
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7469ce0ae01ac444280398a19366fdfc
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+243
@@ -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>
|
||||
public 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
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 79d62a430827aa6449a37bbbb889e26c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Models;
|
||||
using Unity.Pipeline.Runtime.Commands;
|
||||
using Unity.Pipeline.Tests;
|
||||
|
||||
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>
|
||||
public class CodeEvalCommandTests
|
||||
{
|
||||
#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()
|
||||
{
|
||||
var r = CodeEvalCommand.EvaluateCode("return 1;", timeout: 40000);
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.AreEqual("Bad Request", r.Error);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EvaluateCode_RecordsExecutionTime()
|
||||
{
|
||||
var r = CodeEvalCommand.EvaluateCode("return 42;", timeout: 5000);
|
||||
Assert.IsTrue(r.Success, r.Error);
|
||||
Assert.Greater(r.ExecutionTimeMs, 0);
|
||||
}
|
||||
|
||||
#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 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
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0919730cfeec19e40b46ecce9479cf70
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
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>
|
||||
public 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 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>
|
||||
public 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_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>
|
||||
public 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; }
|
||||
}
|
||||
|
||||
public class SampleNestedInput : IStructuredCommandInput
|
||||
{
|
||||
[CliArg("enabled", "Toggle")]
|
||||
public bool Enabled { get; set; }
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9a2753687e13238458ed342206a00d72
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
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>
|
||||
public 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 54c331b153b587c60e89bfbde9bdca1d
|
||||
+310
@@ -0,0 +1,310 @@
|
||||
using System;
|
||||
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>
|
||||
public 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_DoesNotOverflowDroppedCheck()
|
||||
{
|
||||
var buffer = NewBuffer();
|
||||
buffer.Add(LogType.Log, "a", null, k_Ts);
|
||||
|
||||
var response = buffer.Query(long.MaxValue, 100, ConsoleLogBuffer.SeverityLog);
|
||||
|
||||
Assert.AreEqual(0, response.Returned);
|
||||
Assert.IsFalse(response.Dropped);
|
||||
}
|
||||
|
||||
[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 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 524e94027ab20453a5442be5e73f87a1
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9d0b41445c16baf4ca9e5090b4e4ce17
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor.E2E
|
||||
{
|
||||
// =====================================================================================
|
||||
// CLI-196 — End-to-end agent authoring test (SPEC / SKELETON — BLOCKED)
|
||||
//
|
||||
// This file is intentionally inert. It compiles against NUnit ONLY and is [Ignore]d.
|
||||
// It references no authoring command or API, because the commands it will drive do not
|
||||
// exist yet — they land in CLI-191..195. See the plan at:
|
||||
// docs/authoring/plans/CLI-196-docs-samples-e2e.md
|
||||
//
|
||||
// When CLI-191..195 are merged, replace the single Ignored test below with the sequence
|
||||
// documented here, driving each step through an isolated PipelineTestServer exactly like
|
||||
// Tests/Editor/Authoring/FolderCommandsTests.cs (the ViaClient pattern):
|
||||
//
|
||||
// using (var server = new PipelineTestServer())
|
||||
// {
|
||||
// var response = server.Execute("<command>", new { ... });
|
||||
// Assert.IsTrue(response.IsSuccess, response.Error);
|
||||
// // pull globalId / assetPath / guid / instanceId / hierarchyPath out of
|
||||
// // response.JsonResponse["result"] and feed it forward as the next ObjectRef.
|
||||
// }
|
||||
//
|
||||
// Intended end-to-end sequence (dep ticket in brackets; command names are PROVISIONAL —
|
||||
// confirm against the landed tickets before wiring, do not invent commands):
|
||||
//
|
||||
// 0. [CLI-190] set_authoring_root -> "Assets/__CLI196E2E" (reset in TearDown)
|
||||
// 1. [CLI-190] create_folder -> bare "Work" (resolves to Assets/__CLI196E2E/Work;
|
||||
// do NOT repeat the root name or it nests) assert IsValidFolder, has guid
|
||||
// 2. [CLI-191] create_asset / create_material / create_scriptable_object
|
||||
// -> a leaf asset assert assetPath loads non-null
|
||||
// 3. [CLI-193] create_scene -> bare "E2E.unity" (-> Assets/__CLI196E2E/E2E.unity) assert exists + active
|
||||
// 4. [CLI-192] create_gameobject -> /Root, then /Root/Child assert hierarchyPath + instanceId
|
||||
// 5. [CLI-192] add_component -> e.g. UnityEngine.Rigidbody on a target ObjectRef
|
||||
// 6. [CLI-194] create_prefab -> bare "Root.prefab" (-> Assets/__CLI196E2E/Root.prefab) from the hierarchy; assert guid
|
||||
// 7. [CLI-195] create_script (+ recompile + poll recompile_status)
|
||||
// -> bare "E2EBehaviour.cs" (-> Assets/__CLI196E2E/E2EBehaviour.cs); assert exists/compiles
|
||||
// 8. [CLI-192/195] add_component -> attach "E2EBehaviour" to the prefab/GameObject
|
||||
// 9. [CLI-192/195] set_property / set_serialized_reference
|
||||
// -> wire the behaviour's serialized field to the step-2 asset
|
||||
// 10. [CLI-193/194] save_scene / save_prefab (or save_asset)
|
||||
// 11. round-trip verify: re-resolve via globalId/guid (ObjectResolver) and assert the wired
|
||||
// serialized reference still points at the step-2 asset. THIS is the load-bearing assert:
|
||||
// it proves the full create -> wire -> save -> reload loop survives serialization.
|
||||
//
|
||||
// Suggested fixture shape once unblocked:
|
||||
// - [SetUp] : set authoring root to the throwaway folder.
|
||||
// - [TearDown] : ProjectPaths.ResetAuthoringRoot(); delete "Assets/__CLI196E2E";
|
||||
// close/discard the temporary scene; AssetDatabase.Refresh().
|
||||
// - mode : EditMode (this assembly). Run via:
|
||||
// unity command run_tests --mode editor --filter AgentAuthoringE2ETests
|
||||
// =====================================================================================
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end agent-authoring validation: drives the full create -> build -> wire -> save loop
|
||||
/// through pipeline commands and asserts the produced assets/scene exist and the serialized
|
||||
/// reference reads back. Blocked on CLI-191..195 (authoring commands not yet implemented); see
|
||||
/// docs/authoring/plans/CLI-196-docs-samples-e2e.md.
|
||||
/// </summary>
|
||||
public class AgentAuthoringE2ETests
|
||||
{
|
||||
[Test]
|
||||
[Ignore("CLI-196 blocked on CLI-191..195: authoring commands not yet implemented")]
|
||||
public void FullAuthoringLoop_CreatesAssetsSceneAndPrefab_AndSerializedReferenceReadsBack()
|
||||
{
|
||||
// Pending CLI-191..195 — body wired up once the authoring commands land.
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e6b10188957457846bd0037591608c63
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Editor.Commands;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the editor_status command (EditorStatusCommand), exercised directly and via
|
||||
/// PipelineClient.
|
||||
/// </summary>
|
||||
public class EditorStatusCommandTests
|
||||
{
|
||||
#region Direct
|
||||
|
||||
[Test]
|
||||
public void GetEditorStatus_ReturnsValidData()
|
||||
{
|
||||
var status = EditorStatusCommand.GetEditorStatus();
|
||||
|
||||
Assert.IsNotNull(status);
|
||||
Assert.IsNotEmpty(status.Status, "Should report an overall status");
|
||||
Assert.AreEqual(Application.unityVersion, status.UnityVersion, "Should report the Unity version");
|
||||
Assert.IsNotEmpty(status.ProjectPath, "Should report the project path");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetEditorStatus_NotPlaying_ReportsStopped()
|
||||
{
|
||||
// This suite runs in EditMode (not play mode).
|
||||
var status = EditorStatusCommand.GetEditorStatus();
|
||||
Assert.AreEqual("stopped", status.PlayMode);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ViaClient
|
||||
|
||||
[Test]
|
||||
public void EditorStatus_ViaClient_ReturnsValidJson()
|
||||
{
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("editor_status", null);
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, $"editor_status should succeed: {response.Error}");
|
||||
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
|
||||
Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d447dec5ddcb727469e79314e8082e5a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Utility methods for Unity Editor testing scenarios.
|
||||
/// Provides async/await support for Editor state changes and transitions.
|
||||
/// </summary>
|
||||
public static class EditorTestUtilities
|
||||
{
|
||||
public static async Task WaitFor(Func<bool> condition, int timeoutMs = 1000)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
var timeoutCts = new CancellationTokenSource(timeoutMs);
|
||||
timeoutCts.Token.Register(() =>
|
||||
{
|
||||
if (!tcs.Task.IsCompleted)
|
||||
{
|
||||
tcs.SetResult(false); // Indicate timeout, but don't throw
|
||||
}
|
||||
});
|
||||
|
||||
while (!condition())
|
||||
{
|
||||
await Task.Delay(10); // Small delay to avoid busy waiting
|
||||
}
|
||||
}
|
||||
|
||||
[MenuItem("PipelineTests/Invoke Modal!")]
|
||||
public static void InvokeModal()
|
||||
{
|
||||
EditorUtility.DisplayDialog("Hello", "world", "okay", "nope");
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3e7304d92634967439572cc8bb57fc88
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Compilation;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for EvalCodeCompiler's synchronous main-thread compilation/execution.
|
||||
/// eval is a MainThreadRequired command, so the server marshals to the main thread before
|
||||
/// invoking; the compiler runs synchronously on the caller (no background/dispatcher path).
|
||||
/// </summary>
|
||||
public class EvalCodeCompilerTests
|
||||
{
|
||||
[Test]
|
||||
public void CompileAndExecuteOnMainThread_ReturnsValue()
|
||||
{
|
||||
var result = EvalCodeCompiler.CompileAndExecuteOnMainThread("return 42;", 5000, null);
|
||||
|
||||
Assert.IsTrue(result.Success, $"Should succeed. Error: {result.Error}");
|
||||
Assert.AreEqual(42, result.Result, "Should return the evaluated value");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CompileAndExecuteOnMainThread_AccessesUnityApi()
|
||||
{
|
||||
var result = EvalCodeCompiler.CompileAndExecuteOnMainThread("return Application.unityVersion;", 5000, null);
|
||||
|
||||
Assert.IsTrue(result.Success, $"Unity API access should work. Error: {result.Error}");
|
||||
Assert.IsInstanceOf<string>(result.Result, "Unity version should be a string");
|
||||
Assert.IsNotEmpty((string)result.Result, "Unity version should not be empty");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CompileAndExecuteOnMainThread_CompilationError_ReturnsDiagnostics()
|
||||
{
|
||||
var result = EvalCodeCompiler.CompileAndExecuteOnMainThread("return 2 +;", 5000, null);
|
||||
|
||||
Assert.IsFalse(result.Success, "Invalid syntax should fail");
|
||||
Assert.AreEqual("Compilation Failed", result.Error, "Should report a compilation error");
|
||||
Assert.IsNotNull(result.Diagnostics, "Should have diagnostics");
|
||||
Assert.Greater(result.Diagnostics.Count, 0, "Should include compilation diagnostics");
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1e0da069dfce7c846abb7baa50413132
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Runtime.Telemetry;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="FrameStatsSampler"/>'s windowing math. These run in EditMode without a
|
||||
/// Player or RuntimePipelineManager — the sampler is a plain class fed synthetic frame times.
|
||||
/// </summary>
|
||||
public class FrameStatsSamplerTests
|
||||
{
|
||||
private const float Tolerance = 0.001f;
|
||||
|
||||
[Test]
|
||||
public void NewSampler_HasNoSamples_SnapshotUnavailable()
|
||||
{
|
||||
using (var sampler = new FrameStatsSampler())
|
||||
{
|
||||
var snap = sampler.GetSnapshot();
|
||||
|
||||
Assert.IsFalse(snap.Available, "a sampler with no frames must report unavailable");
|
||||
Assert.AreEqual(0, snap.SampleWindow);
|
||||
Assert.IsNotNull(snap.Counters, "counters map should never be null");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Sample_ComputesAverageMinMaxAndFps()
|
||||
{
|
||||
using (var sampler = new FrameStatsSampler())
|
||||
{
|
||||
// 10ms, 20ms, 30ms -> avg 20ms -> 50 fps.
|
||||
sampler.Sample(0.010f);
|
||||
sampler.Sample(0.020f);
|
||||
sampler.Sample(0.030f);
|
||||
|
||||
var snap = sampler.GetSnapshot();
|
||||
|
||||
Assert.IsTrue(snap.Available);
|
||||
Assert.AreEqual(3, snap.SampleWindow);
|
||||
Assert.AreEqual(20f, snap.AverageFrameTimeMs, Tolerance);
|
||||
Assert.AreEqual(10f, snap.MinFrameTimeMs, Tolerance);
|
||||
Assert.AreEqual(30f, snap.MaxFrameTimeMs, Tolerance);
|
||||
Assert.AreEqual(30f, snap.LastFrameTimeMs, Tolerance);
|
||||
Assert.AreEqual(50f, snap.Fps, 0.01f);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Sample_BeyondCapacity_KeepsRollingWindow()
|
||||
{
|
||||
using (var sampler = new FrameStatsSampler(windowFrames: 3))
|
||||
{
|
||||
Assert.AreEqual(3, sampler.Capacity);
|
||||
|
||||
// Feed 5 frames into a 3-frame window; only the last three (30/40/50ms) should survive.
|
||||
sampler.Sample(0.010f);
|
||||
sampler.Sample(0.020f);
|
||||
sampler.Sample(0.030f);
|
||||
sampler.Sample(0.040f);
|
||||
sampler.Sample(0.050f);
|
||||
|
||||
var snap = sampler.GetSnapshot();
|
||||
|
||||
Assert.AreEqual(3, snap.SampleWindow, "window must not exceed capacity");
|
||||
Assert.AreEqual(40f, snap.AverageFrameTimeMs, Tolerance);
|
||||
Assert.AreEqual(30f, snap.MinFrameTimeMs, Tolerance);
|
||||
Assert.AreEqual(50f, snap.MaxFrameTimeMs, Tolerance);
|
||||
Assert.AreEqual(50f, snap.LastFrameTimeMs, Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Sample_ZeroDelta_DoesNotDivideByZeroFps()
|
||||
{
|
||||
using (var sampler = new FrameStatsSampler())
|
||||
{
|
||||
sampler.Sample(0f);
|
||||
|
||||
var snap = sampler.GetSnapshot();
|
||||
|
||||
Assert.IsTrue(snap.Available);
|
||||
Assert.AreEqual(0f, snap.Fps, Tolerance, "zero average frame time must yield 0 fps, not Infinity/NaN");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7f0d8ca052ad4db2baf439859a396d07
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cfbbfdc51a7141dc947b6d4a3791cd67
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+377
@@ -0,0 +1,377 @@
|
||||
using System.Collections.Generic;
|
||||
using NUnit.Framework;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Unity.Pipeline.Editor.Commands.GameObjects;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using Object = UnityEngine.Object;
|
||||
using Unity.Pipeline;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor.GameObjects
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the shared string-encoded JSON parameter coercion (CLI-219 / CLI-220) and the batch
|
||||
/// <c>create_gameobjects</c> command (CLI-223).
|
||||
///
|
||||
/// CLI-219/220 are a single root cause: when an agent/CLI passes a structured parameter as a
|
||||
/// JSON-ENCODED STRING (position "[1,2,3]", properties "{\"m_Mass\":0.17}"), the server must
|
||||
/// re-parse it before converting. The ViaClient tests below pass those forms as real strings to
|
||||
/// prove the coercion; the Direct tests pass real typed values to prove the typed path still works.
|
||||
/// The guard is also pinned: a plain string param and an ObjectRef string handle ("/Name") must NOT
|
||||
/// be re-parsed.
|
||||
/// </summary>
|
||||
public class CoercionAndBatchTests
|
||||
{
|
||||
private readonly List<GameObject> m_Spawned = new List<GameObject>();
|
||||
|
||||
private GameObject Track(GameObject go)
|
||||
{
|
||||
m_Spawned.Add(go);
|
||||
return go;
|
||||
}
|
||||
|
||||
private static ObjectRef RefTo(Object obj) => new ObjectRef { InstanceId = PipelineUtils.GetObjectId(obj) };
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
foreach (var go in m_Spawned)
|
||||
{
|
||||
if (go != null)
|
||||
Object.DestroyImmediate(go);
|
||||
}
|
||||
|
||||
m_Spawned.Clear();
|
||||
}
|
||||
|
||||
#region CLI-219 set_transform
|
||||
|
||||
[Test]
|
||||
public void SetTransform_Direct_TypedArrays_Apply()
|
||||
{
|
||||
var go = Track(new GameObject("Xform219_Direct"));
|
||||
|
||||
GameObjectCommands.SetTransform(RefTo(go),
|
||||
position: new[] { 1f, 2f, 3f },
|
||||
rotation: new[] { 0f, 90f, 0f },
|
||||
scale: new[] { 2f, 2f, 2f });
|
||||
|
||||
Assert.AreEqual(new Vector3(1, 2, 3), go.transform.localPosition);
|
||||
Assert.AreEqual(new Vector3(2, 2, 2), go.transform.localScale);
|
||||
Assert.AreEqual(90f, go.transform.localEulerAngles.y, 0.01f);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetTransform_ViaClient_ArrayParams_Apply()
|
||||
{
|
||||
var go = Track(new GameObject("Xform219_Arrays"));
|
||||
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("set_transform", new
|
||||
{
|
||||
target = new { instanceId = PipelineUtils.GetObjectId(go) },
|
||||
position = new[] { 1f, 2f, 3f },
|
||||
rotation = new[] { 0f, 90f, 0f },
|
||||
scale = new[] { 2f, 2f, 2f }
|
||||
});
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, $"set_transform should succeed: {response.Error}");
|
||||
Assert.AreEqual(new Vector3(1, 2, 3), go.transform.localPosition);
|
||||
Assert.AreEqual(new Vector3(2, 2, 2), go.transform.localScale);
|
||||
Assert.AreEqual(90f, go.transform.localEulerAngles.y, 0.01f);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetTransform_ViaClient_JsonStringParams_Coerced()
|
||||
{
|
||||
// The CLI-219 repro: position/rotation/scale arrive as JSON-encoded STRINGS. Before the
|
||||
// coercion these convert to null (JValue(string).ToObject(float[]) == null) and the command
|
||||
// silently applies nothing. They must now be re-parsed and applied.
|
||||
var go = Track(new GameObject("Xform219_Strings"));
|
||||
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("set_transform", new
|
||||
{
|
||||
target = new { instanceId = PipelineUtils.GetObjectId(go) },
|
||||
position = "[1,2,3]",
|
||||
rotation = "[0,90,0]",
|
||||
scale = "[2,2,2]"
|
||||
});
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, $"set_transform should succeed: {response.Error}");
|
||||
Assert.AreEqual(new Vector3(1, 2, 3), go.transform.localPosition,
|
||||
"String-encoded position must be coerced and applied");
|
||||
Assert.AreEqual(new Vector3(2, 2, 2), go.transform.localScale,
|
||||
"String-encoded scale must be coerced and applied");
|
||||
Assert.AreEqual(90f, go.transform.localEulerAngles.y, 0.01f,
|
||||
"String-encoded rotation must be coerced and applied");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetTransform_ViaClient_NestedTarget_StringParams_Coerced()
|
||||
{
|
||||
// Cover a parented/nested target too: the handle resolves the child, and the string-encoded
|
||||
// position applies as a LOCAL transform on the child.
|
||||
var parent = Track(new GameObject("Xform219_Parent"));
|
||||
var child = Track(new GameObject("Xform219_Child"));
|
||||
child.transform.SetParent(parent.transform);
|
||||
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("set_transform", new
|
||||
{
|
||||
target = new { instanceId = PipelineUtils.GetObjectId(child) },
|
||||
position = "[4,5,6]"
|
||||
});
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, $"set_transform should succeed: {response.Error}");
|
||||
Assert.AreEqual(parent.transform, child.transform.parent, "Child stays parented");
|
||||
Assert.AreEqual(new Vector3(4, 5, 6), child.transform.localPosition,
|
||||
"String-encoded local position must apply to the nested child");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CLI-220 set_component_properties
|
||||
|
||||
[Test]
|
||||
public void SetComponentProperties_Direct_JObject_Applies()
|
||||
{
|
||||
var go = Track(new GameObject("Props220_Direct"));
|
||||
var rb = go.AddComponent<Rigidbody>();
|
||||
|
||||
ComponentCommands.SetComponentProperties(RefTo(rb), new JObject { ["m_Mass"] = 0.17f });
|
||||
|
||||
Assert.AreEqual(0.17f, rb.mass, 0.0001f, "Real JObject properties must still apply");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetComponentProperties_ViaClient_PropertiesAsJsonString_Coerced()
|
||||
{
|
||||
// The CLI-220 repro: `properties` arrives as a JSON-encoded STRING. Before the coercion it
|
||||
// converts to null and the command fails "Required parameter 'properties' is missing or
|
||||
// empty". It must now be re-parsed into a JObject and applied.
|
||||
var go = Track(new GameObject("Props220_String"));
|
||||
var rb = go.AddComponent<Rigidbody>();
|
||||
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("set_component_properties", new
|
||||
{
|
||||
target = new { instanceId = PipelineUtils.GetObjectId(rb) },
|
||||
type = "Rigidbody",
|
||||
properties = "{\"m_Mass\":0.17}"
|
||||
});
|
||||
|
||||
Assert.IsTrue(response.IsSuccess,
|
||||
$"set_component_properties should succeed with a string-encoded properties: {response.Error}");
|
||||
Assert.AreEqual(0.17f, rb.mass, 0.0001f,
|
||||
"String-encoded properties must be coerced and applied");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CLI-223 create_gameobjects (batch)
|
||||
|
||||
[Test]
|
||||
public void CreateGameObjects_Direct_CountOne_ReturnsSingleIdentity()
|
||||
{
|
||||
// Regression: count=1 keeps the bare name (no "1" suffix) and returns exactly one item.
|
||||
var result = GameObjectCommands.CreateGameObjects(name: "Batch223_One", primitive: "cube", count: 1);
|
||||
|
||||
Assert.AreEqual(1, result.Count);
|
||||
Assert.AreEqual(1, result.GameObjects.Count);
|
||||
|
||||
var go = PipelineUtils.IdToObject(result.GameObjects[0].InstanceId.Value) as GameObject;
|
||||
Track(go);
|
||||
Assert.IsNotNull(go, "Created object should be resolvable by instanceId");
|
||||
Assert.AreEqual("Batch223_One", go.name, "count=1 must not append a numeric suffix");
|
||||
Assert.IsNotNull(go.GetComponent<BoxCollider>(), "Cube primitive should have a BoxCollider");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateGameObjects_Direct_CountN_WithPositions_AppliesEach()
|
||||
{
|
||||
var positions = new[]
|
||||
{
|
||||
new[] { 0f, 0f, 0f },
|
||||
new[] { 1f, 0f, 0f },
|
||||
new[] { 2f, 0f, 0f },
|
||||
};
|
||||
|
||||
var result = GameObjectCommands.CreateGameObjects(
|
||||
name: "Batch223_Pos", primitive: "sphere", count: 3, positions: positions);
|
||||
|
||||
Assert.AreEqual(3, result.Count);
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var go = PipelineUtils.IdToObject(result.GameObjects[i].InstanceId.Value) as GameObject;
|
||||
Track(go);
|
||||
Assert.AreEqual("Batch223_Pos" + (i + 1), go.name, "count>1 must suffix Name1..NameN");
|
||||
Assert.AreEqual(new Vector3(i, 0, 0), go.transform.localPosition,
|
||||
"Each object must take its per-index position");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateGameObjects_Direct_CountN_NoPositions_StackedAtOrigin()
|
||||
{
|
||||
var result = GameObjectCommands.CreateGameObjects(name: "Batch223_Origin", count: 4);
|
||||
|
||||
Assert.AreEqual(4, result.Count);
|
||||
foreach (var item in result.GameObjects)
|
||||
{
|
||||
var go = PipelineUtils.IdToObject(item.InstanceId.Value) as GameObject;
|
||||
Track(go);
|
||||
Assert.AreEqual(Vector3.zero, go.transform.localPosition,
|
||||
"With no positions array, all objects stay at the origin");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateGameObjects_Direct_MismatchedPositionsLength_Throws()
|
||||
{
|
||||
// 2 vectors for count=3 is an agent error and must reject before creating anything.
|
||||
var positions = new[]
|
||||
{
|
||||
new[] { 0f, 0f, 0f },
|
||||
new[] { 1f, 0f, 0f },
|
||||
};
|
||||
|
||||
Assert.Throws<System.ArgumentException>(() =>
|
||||
GameObjectCommands.CreateGameObjects(name: "Batch223_Bad", count: 3, positions: positions));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateGameObjects_Direct_NullPositionEntry_ThrowsWithIndex()
|
||||
{
|
||||
// A positions array whose LENGTH matches count but contains a null entry must fail with a
|
||||
// clear, index-bearing ArgumentException (not a NullReferenceException), up front, before any
|
||||
// object is created.
|
||||
var positions = new[]
|
||||
{
|
||||
new[] { 0f, 0f, 0f },
|
||||
null,
|
||||
new[] { 2f, 0f, 0f },
|
||||
};
|
||||
|
||||
var ex = Assert.Throws<System.ArgumentException>(() =>
|
||||
GameObjectCommands.CreateGameObjects(name: "Batch223_NullEntry", count: 3, positions: positions));
|
||||
StringAssert.Contains("positions[1]", ex.Message, "The error should name the offending index");
|
||||
|
||||
// Validation runs before creation, so nothing should have been spawned.
|
||||
var leaked = GameObjectCommands.FindGameObjects(name: "Batch223_NullEntry1");
|
||||
Assert.AreEqual(0, leaked.Count, "A rejected batch must create nothing");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateGameObjects_ViaClient_CountN_WithStringEncodedPositions()
|
||||
{
|
||||
// End-to-end: float[][] positions arrive as a JSON STRING (the CLI-219/220 coercion also
|
||||
// re-parses string-encoded nested arrays), and each object takes its per-index position.
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("create_gameobjects", new
|
||||
{
|
||||
name = "Batch223_Via",
|
||||
primitive = "cube",
|
||||
count = 3,
|
||||
positions = "[[0,0,0],[1,0,0],[2,0,0]]"
|
||||
});
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, $"create_gameobjects should succeed: {response.Error}");
|
||||
|
||||
var count = response.JsonResponse["result"]?["count"]?.ToObject<int>();
|
||||
Assert.AreEqual(3, count, "Batch should report 3 created");
|
||||
|
||||
var gameObjects = response.JsonResponse["result"]?["gameObjects"] as JArray;
|
||||
Assert.IsNotNull(gameObjects, "Result should carry a gameObjects array");
|
||||
Assert.AreEqual(3, gameObjects.Count);
|
||||
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var instanceId = gameObjects[i]?["instanceId"]?.ToObject<ObjectId?>();
|
||||
Assert.IsTrue(instanceId.HasValue, "Each created object should carry an instanceId");
|
||||
var go = PipelineUtils.IdToObject(instanceId.Value) as GameObject;
|
||||
Track(go);
|
||||
Assert.IsNotNull(go, "Created object should be resolvable");
|
||||
Assert.AreEqual(new Vector3(i, 0, 0), go.transform.localPosition,
|
||||
"Each object must take its per-index position (string-encoded array coerced)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateGameObjects_ViaClient_CountOne_Regression()
|
||||
{
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("create_gameobjects", new { name = "Batch223_ViaOne", count = 1 });
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, $"create_gameobjects should succeed: {response.Error}");
|
||||
var count = response.JsonResponse["result"]?["count"]?.ToObject<int>();
|
||||
Assert.AreEqual(1, count);
|
||||
|
||||
var instanceId = response.JsonResponse["result"]?["gameObjects"]?[0]?["instanceId"]?.ToObject<ObjectId?>();
|
||||
Assert.IsTrue(instanceId.HasValue);
|
||||
var go = PipelineUtils.IdToObject(instanceId.Value) as GameObject;
|
||||
Track(go);
|
||||
Assert.AreEqual("Batch223_ViaOne", go.name, "count=1 must not append a numeric suffix");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Coercion guard (does not break plain strings / ObjectRef handles)
|
||||
|
||||
[Test]
|
||||
public void Coercion_PlainStringParam_NotReparsed()
|
||||
{
|
||||
// rename_gameobject takes a plain string 'name'. A normal string must pass straight through
|
||||
// (it does not start with '{' or '[', so the guard never touches it).
|
||||
var go = Track(new GameObject("Guard_Old"));
|
||||
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("rename_gameobject", new
|
||||
{
|
||||
target = new { instanceId = PipelineUtils.GetObjectId(go) },
|
||||
name = "Guard_New"
|
||||
});
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, $"rename_gameobject should succeed: {response.Error}");
|
||||
Assert.AreEqual("Guard_New", go.name, "Plain string param must apply unchanged");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Coercion_ObjectRefStringHandle_StillResolves()
|
||||
{
|
||||
// An ObjectRef passed as a bare string handle ("/Name") must continue flowing to
|
||||
// ObjectRefConverter — it does not start with '{' or '[', so the coercion leaves it alone.
|
||||
var go = Track(new GameObject("Guard_HandleTarget"));
|
||||
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("set_active", new
|
||||
{
|
||||
target = "/Guard_HandleTarget",
|
||||
active = false
|
||||
});
|
||||
|
||||
Assert.IsTrue(response.IsSuccess,
|
||||
$"set_active with a string handle should succeed: {response.Error}");
|
||||
Assert.IsFalse(go.activeSelf, "ObjectRef string handle must still resolve and apply");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: be72bf79d228f86448e2d3fc5ed00c17
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+400
@@ -0,0 +1,400 @@
|
||||
using System.Collections.Generic;
|
||||
using NUnit.Framework;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Unity.Pipeline.Editor.Commands.GameObjects;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using Object = UnityEngine.Object;
|
||||
using Unity.Pipeline;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor.GameObjects
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the component authoring commands (CLI-192), exercised directly and via
|
||||
/// <see cref="PipelineTestServer"/>. Covers add/remove, a serialized-property round trip
|
||||
/// (set -> get) over primitive + enum + Vector types, object-reference assignment via a resolved
|
||||
/// handle, and the Undo-revert contract for a property mutation.
|
||||
/// </summary>
|
||||
public class ComponentCommandsTests
|
||||
{
|
||||
private readonly List<GameObject> m_Spawned = new List<GameObject>();
|
||||
|
||||
private GameObject Track(GameObject go)
|
||||
{
|
||||
m_Spawned.Add(go);
|
||||
return go;
|
||||
}
|
||||
|
||||
private static ObjectRef RefTo(Object obj) => new ObjectRef { InstanceId = PipelineUtils.GetObjectId(obj) };
|
||||
|
||||
// Temp asset folder for the CLI-220 follow-up tests (asset references + arrays); cleaned in TearDown.
|
||||
private const string AssetRoot = "Assets/__CLI220Test";
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
foreach (var go in m_Spawned)
|
||||
{
|
||||
if (go != null)
|
||||
Object.DestroyImmediate(go);
|
||||
}
|
||||
|
||||
m_Spawned.Clear();
|
||||
|
||||
if (AssetDatabase.IsValidFolder(AssetRoot))
|
||||
{
|
||||
AssetDatabase.DeleteAsset(AssetRoot);
|
||||
AssetDatabase.Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureAssetRoot()
|
||||
{
|
||||
if (!AssetDatabase.IsValidFolder(AssetRoot))
|
||||
AssetDatabase.CreateFolder("Assets", "__CLI220Test");
|
||||
}
|
||||
|
||||
/// <summary>Pick a shader that resolves on this project's active pipeline.</summary>
|
||||
private static Shader KnownShader()
|
||||
{
|
||||
return Shader.Find("Standard")
|
||||
?? Shader.Find("Universal Render Pipeline/Lit")
|
||||
?? Shader.Find("Sprites/Default");
|
||||
}
|
||||
|
||||
/// <summary>Create a temporary Material asset under <see cref="AssetRoot"/> and return the loaded asset.</summary>
|
||||
private static Material CreateTempMaterial(string name)
|
||||
{
|
||||
EnsureAssetRoot();
|
||||
var mat = new Material(KnownShader()) { name = name };
|
||||
var path = $"{AssetRoot}/{name}.mat";
|
||||
AssetDatabase.CreateAsset(mat, path);
|
||||
AssetDatabase.SaveAssets();
|
||||
return AssetDatabase.LoadAssetAtPath<Material>(path);
|
||||
}
|
||||
|
||||
/// <summary>Create a temporary Mesh asset (a shaderless, version-stable scalar object reference).</summary>
|
||||
private static Mesh CreateTempMesh(string name)
|
||||
{
|
||||
EnsureAssetRoot();
|
||||
var mesh = new Mesh { name = name };
|
||||
var path = $"{AssetRoot}/{name}.asset";
|
||||
AssetDatabase.CreateAsset(mesh, path);
|
||||
AssetDatabase.SaveAssets();
|
||||
return AssetDatabase.LoadAssetAtPath<Mesh>(path);
|
||||
}
|
||||
|
||||
#region Direct
|
||||
|
||||
[Test]
|
||||
public void AddComponent_ByShortName_Adds()
|
||||
{
|
||||
var go = Track(new GameObject("AddComp_CLI192"));
|
||||
var result = ComponentCommands.AddComponent(RefTo(go), "Rigidbody");
|
||||
|
||||
Assert.IsNotNull(go.GetComponent<Rigidbody>(), "Rigidbody should be added");
|
||||
Assert.AreEqual("Rigidbody", result.Type);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddComponent_ByFullyQualifiedName_Adds()
|
||||
{
|
||||
var go = Track(new GameObject("AddCompFq_CLI192"));
|
||||
ComponentCommands.AddComponent(RefTo(go), "UnityEngine.Rigidbody");
|
||||
Assert.IsNotNull(go.GetComponent<Rigidbody>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RemoveComponent_ByGameObjectAndType_Removes()
|
||||
{
|
||||
var go = Track(new GameObject("RemoveComp_CLI192"));
|
||||
go.AddComponent<Rigidbody>();
|
||||
|
||||
ComponentCommands.RemoveComponent(RefTo(go), "Rigidbody");
|
||||
|
||||
Assert.IsNull(go.GetComponent<Rigidbody>(), "Rigidbody should be removed");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetGetProperties_PrimitiveRoundTrip()
|
||||
{
|
||||
var go = Track(new GameObject("PropPrim_CLI192"));
|
||||
var rb = go.AddComponent<Rigidbody>();
|
||||
|
||||
ComponentCommands.SetComponentProperties(RefTo(rb),
|
||||
new JObject { ["m_Mass"] = 12.5f });
|
||||
|
||||
Assert.AreEqual(12.5f, rb.mass, 0.001f, "Mass should be set on the live component");
|
||||
|
||||
var read = ComponentCommands.GetComponentProperties(RefTo(rb));
|
||||
Assert.AreEqual(12.5, read.Properties["m_Mass"].ToObject<double>(), 0.001,
|
||||
"get should read back the value that set wrote");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetProperties_EnumByName_Applies()
|
||||
{
|
||||
var go = Track(new GameObject("PropEnum_CLI192"));
|
||||
var rb = go.AddComponent<Rigidbody>();
|
||||
|
||||
// m_CollisionDetection maps to the CollisionDetectionMode enum.
|
||||
ComponentCommands.SetComponentProperties(RefTo(rb),
|
||||
new JObject { ["m_CollisionDetection"] = "Continuous" });
|
||||
|
||||
Assert.AreEqual(CollisionDetectionMode.Continuous, rb.collisionDetectionMode,
|
||||
"Enum set by name should apply");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetProperties_VectorType_Applies()
|
||||
{
|
||||
var go = Track(new GameObject("PropVec_CLI192"));
|
||||
var box = go.AddComponent<BoxCollider>();
|
||||
|
||||
ComponentCommands.SetComponentProperties(RefTo(box),
|
||||
new JObject { ["m_Size"] = new JArray(2f, 3f, 4f) });
|
||||
|
||||
Assert.AreEqual(new Vector3(2, 3, 4), box.size, "Vector3 property should be set from a JSON array");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetProperties_ObjectReference_ByResolvedHandle()
|
||||
{
|
||||
// Create a real Mesh asset on disk so it resolves through ObjectResolver by path. A Mesh is
|
||||
// a version-stable, single object-reference target (MeshFilter.m_Mesh).
|
||||
const string dir = "Assets/__CLI192ObjRefTest";
|
||||
if (!AssetDatabase.IsValidFolder(dir))
|
||||
AssetDatabase.CreateFolder("Assets", "__CLI192ObjRefTest");
|
||||
var meshPath = dir + "/RefMesh.asset";
|
||||
var mesh = new Mesh { name = "RefMesh_CLI192" };
|
||||
AssetDatabase.CreateAsset(mesh, meshPath);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
try
|
||||
{
|
||||
var go = Track(new GameObject("PropObjRef_CLI192"));
|
||||
var mf = go.AddComponent<MeshFilter>();
|
||||
|
||||
// MeshFilter.m_Mesh is a single Mesh object reference.
|
||||
ComponentCommands.SetComponentProperties(RefTo(mf),
|
||||
new JObject { ["m_Mesh"] = new JObject { ["path"] = meshPath } });
|
||||
|
||||
Assert.IsNotNull(mf.sharedMesh, "Object reference should be assigned from the resolved handle");
|
||||
Assert.AreEqual(mesh, mf.sharedMesh, "Should be the exact resolved asset");
|
||||
}
|
||||
finally
|
||||
{
|
||||
AssetDatabase.DeleteAsset(dir);
|
||||
AssetDatabase.Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetProperties_UnknownProperty_Throws()
|
||||
{
|
||||
var go = Track(new GameObject("PropBad_CLI192"));
|
||||
var rb = go.AddComponent<Rigidbody>();
|
||||
|
||||
Assert.Throws<System.ArgumentException>(() =>
|
||||
ComponentCommands.SetComponentProperties(RefTo(rb),
|
||||
new JObject { ["m_NoSuchProperty"] = 1 }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetProperties_IsUndone()
|
||||
{
|
||||
var go = Track(new GameObject("PropUndo_CLI192"));
|
||||
var rb = go.AddComponent<Rigidbody>();
|
||||
rb.mass = 1f;
|
||||
|
||||
ComponentCommands.SetComponentProperties(RefTo(rb), new JObject { ["m_Mass"] = 9f });
|
||||
Assert.AreEqual(9f, rb.mass, 0.001f);
|
||||
|
||||
Undo.PerformUndo();
|
||||
Assert.AreEqual(1f, rb.mass, 0.001f, "Undo should revert the property change");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ViaClient
|
||||
|
||||
[Test]
|
||||
public void AddComponent_ViaClient_Succeeds()
|
||||
{
|
||||
var go = Track(new GameObject("ViaAdd_CLI192"));
|
||||
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("add_component",
|
||||
new { target = new { instanceId = PipelineUtils.GetObjectId(go) }, type = "Rigidbody" });
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, $"add_component should succeed: {response.Error}");
|
||||
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
|
||||
Assert.IsNotNull(go.GetComponent<Rigidbody>(), "Rigidbody should be added via client");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetComponentProperties_ViaClient_RoundTrips()
|
||||
{
|
||||
var go = Track(new GameObject("ViaProp_CLI192"));
|
||||
var rb = go.AddComponent<Rigidbody>();
|
||||
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("set_component_properties", new
|
||||
{
|
||||
target = new { instanceId = PipelineUtils.GetObjectId(rb) },
|
||||
properties = new { m_Mass = 7.0 }
|
||||
});
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, $"set_component_properties should succeed: {response.Error}");
|
||||
Assert.AreEqual(7f, rb.mass, 0.001f, "Mass should be set via client");
|
||||
|
||||
var massBack = response.JsonResponse["result"]?["properties"]?["m_Mass"]?.ToObject<double>();
|
||||
Assert.AreEqual(7.0, massBack.Value, 0.001, "Result should echo the committed value");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CLI-220 follow-ups — asset references + array properties
|
||||
|
||||
[Test]
|
||||
public void SetProperties_AssetReference_ByInstanceId_Assigns()
|
||||
{
|
||||
// The core of the bug: an ASSET reference addressed by instanceId must actually resolve and
|
||||
// assign (it used to silently no-op and report success).
|
||||
var mesh = CreateTempMesh("ByInstanceId");
|
||||
var go = Track(new GameObject("PropMeshInst_CLI220"));
|
||||
var mf = go.AddComponent<MeshFilter>();
|
||||
|
||||
ComponentCommands.SetComponentProperties(RefTo(mf),
|
||||
new JObject { ["m_Mesh"] = new JObject { ["instanceId"] = PipelineUtils.GetObjectId(mesh).RawValue } });
|
||||
|
||||
Assert.AreEqual(mesh, mf.sharedMesh, "Asset reference by instanceId should be assigned");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetProperties_AssetReference_ByGuid_Assigns()
|
||||
{
|
||||
var mesh = CreateTempMesh("ByGuid");
|
||||
AssetDatabase.TryGetGUIDAndLocalFileIdentifier(mesh, out var guid, out long _);
|
||||
|
||||
var go = Track(new GameObject("PropMeshGuid_CLI220"));
|
||||
var mf = go.AddComponent<MeshFilter>();
|
||||
|
||||
ComponentCommands.SetComponentProperties(RefTo(mf),
|
||||
new JObject { ["m_Mesh"] = new JObject { ["guid"] = guid } });
|
||||
|
||||
Assert.AreEqual(mesh, mf.sharedMesh, "Asset reference by guid should be assigned");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetProperties_UnresolvableReference_Throws()
|
||||
{
|
||||
// Previously this returned success while assigning nothing. It must now fail loudly.
|
||||
var go = Track(new GameObject("PropBadRef_CLI220"));
|
||||
var mf = go.AddComponent<MeshFilter>();
|
||||
|
||||
Assert.Throws<System.ArgumentException>(() =>
|
||||
ComponentCommands.SetComponentProperties(RefTo(mf),
|
||||
new JObject { ["m_Mesh"] = new JObject { ["instanceId"] = 999999999 } }),
|
||||
"An unresolvable object reference must throw, not silently succeed.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetProperties_ArrayOfObjectReferences_Assigns()
|
||||
{
|
||||
var matA = CreateTempMaterial("ArrA");
|
||||
var matB = CreateTempMaterial("ArrB");
|
||||
|
||||
var go = Track(new GameObject("PropMatArray_CLI220"));
|
||||
var renderer = go.AddComponent<MeshRenderer>();
|
||||
|
||||
// MeshRenderer.m_Materials is an object-reference array (Generic+isArray).
|
||||
ComponentCommands.SetComponentProperties(RefTo(renderer), new JObject
|
||||
{
|
||||
["m_Materials"] = new JArray(
|
||||
new JObject { ["path"] = AssetDatabase.GetAssetPath(matA) },
|
||||
new JObject { ["path"] = AssetDatabase.GetAssetPath(matB) })
|
||||
});
|
||||
|
||||
Assert.AreEqual(2, renderer.sharedMaterials.Length, "Array property should be sized to the JSON array");
|
||||
Assert.AreEqual(matA, renderer.sharedMaterials[0]);
|
||||
Assert.AreEqual(matB, renderer.sharedMaterials[1]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetProperties_Array_ReadsBack()
|
||||
{
|
||||
var matA = CreateTempMaterial("ReadA");
|
||||
var matB = CreateTempMaterial("ReadB");
|
||||
|
||||
var go = Track(new GameObject("PropMatArrayRead_CLI220"));
|
||||
var renderer = go.AddComponent<MeshRenderer>();
|
||||
renderer.sharedMaterials = new[] { matA, matB };
|
||||
|
||||
var read = ComponentCommands.GetComponentProperties(RefTo(renderer));
|
||||
var materials = read.Properties["m_Materials"] as JArray;
|
||||
|
||||
Assert.IsNotNull(materials, "Array property should read back as a JSON array");
|
||||
Assert.AreEqual(2, materials.Count, "Array read should report both elements");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetProperties_ViaClient_AssetByCapitalInstanceID_Assigns()
|
||||
{
|
||||
// The agent's exact repro: a handle with a capital-D "instanceID" key. It must bind
|
||||
// (case-insensitive) and assign the asset over the wire.
|
||||
var mesh = CreateTempMesh("ViaCapital");
|
||||
var go = Track(new GameObject("PropMeshCap_CLI220"));
|
||||
var mf = go.AddComponent<MeshFilter>();
|
||||
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("set_component_properties", new
|
||||
{
|
||||
target = new { instanceId = PipelineUtils.GetObjectId(mf) },
|
||||
properties = new { m_Mesh = new { instanceID = PipelineUtils.GetObjectId(mesh) } }
|
||||
});
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, $"set_component_properties should succeed: {response.Error}");
|
||||
Assert.AreEqual(mesh, mf.sharedMesh, "Capital 'instanceID' handle must resolve and assign the asset");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetProperties_ViaClient_MaterialArray_Assigns()
|
||||
{
|
||||
var matA = CreateTempMaterial("ViaArrA");
|
||||
var matB = CreateTempMaterial("ViaArrB");
|
||||
var go = Track(new GameObject("PropMatArrayVia_CLI220"));
|
||||
var renderer = go.AddComponent<MeshRenderer>();
|
||||
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("set_component_properties", new
|
||||
{
|
||||
target = new { instanceId = PipelineUtils.GetObjectId(renderer) },
|
||||
properties = new
|
||||
{
|
||||
m_Materials = new object[]
|
||||
{
|
||||
new { path = AssetDatabase.GetAssetPath(matA) },
|
||||
new { path = AssetDatabase.GetAssetPath(matB) }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, $"set_component_properties (array) should succeed: {response.Error}");
|
||||
Assert.AreEqual(2, renderer.sharedMaterials.Length, "Material array should be set via client");
|
||||
Assert.AreEqual(matA, renderer.sharedMaterials[0]);
|
||||
Assert.AreEqual(matB, renderer.sharedMaterials[1]);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cfcba8e8d69748c7a4facfd2b2ecff17
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Editor.Commands.GameObjects;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using Object = UnityEngine.Object;
|
||||
using Unity.Pipeline;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor.GameObjects
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the GameObject authoring commands (CLI-192), exercised directly and via
|
||||
/// <see cref="PipelineTestServer"/>. Covers creation (empty + primitive), hierarchy build via
|
||||
/// set_parent, the find query, transform/active/tag/layer/rename mutations, deletion, and the
|
||||
/// Undo-revert contract. Test GameObjects are created with plain UnityEngine/UnityEditor APIs and
|
||||
/// torn down explicitly so the suite leaves no scene residue.
|
||||
/// </summary>
|
||||
public class GameObjectCommandsTests
|
||||
{
|
||||
private readonly List<GameObject> m_Spawned = new List<GameObject>();
|
||||
|
||||
private GameObject Track(GameObject go)
|
||||
{
|
||||
m_Spawned.Add(go);
|
||||
return go;
|
||||
}
|
||||
|
||||
private static ObjectRef RefTo(Object obj) => new ObjectRef { InstanceId = PipelineUtils.GetObjectId(obj) };
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
foreach (var go in m_Spawned)
|
||||
{
|
||||
if (go != null)
|
||||
Object.DestroyImmediate(go);
|
||||
}
|
||||
|
||||
m_Spawned.Clear();
|
||||
}
|
||||
|
||||
#region Direct
|
||||
|
||||
[Test]
|
||||
public void CreateGameObject_Empty_ReturnsIdentity()
|
||||
{
|
||||
var result = GameObjectCommands.CreateGameObject("Empty_CLI192");
|
||||
var go = PipelineUtils.IdToObject(result.InstanceId.Value) as GameObject;
|
||||
Track(go);
|
||||
|
||||
Assert.IsNotNull(go, "Created object should be resolvable by instanceId");
|
||||
Assert.AreEqual("Empty_CLI192", go.name);
|
||||
Assert.IsNull(go.GetComponent<MeshFilter>(), "Empty GO has no mesh");
|
||||
Assert.IsNotNull(result.HierarchyPath, "Scene object should report a hierarchy path");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateGameObject_Primitive_AttachesMeshAndCollider()
|
||||
{
|
||||
var result = GameObjectCommands.CreateGameObject("Cube_CLI192", "cube");
|
||||
var go = PipelineUtils.IdToObject(result.InstanceId.Value) as GameObject;
|
||||
Track(go);
|
||||
|
||||
Assert.IsNotNull(go.GetComponent<MeshFilter>(), "Primitive cube should have a MeshFilter");
|
||||
Assert.IsNotNull(go.GetComponent<BoxCollider>(), "Primitive cube should have a BoxCollider");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateGameObject_WithParent_BuildsHierarchy()
|
||||
{
|
||||
var parent = Track(new GameObject("Parent_CLI192"));
|
||||
var childResult = GameObjectCommands.CreateGameObject("Child_CLI192", null, RefTo(parent));
|
||||
var child = PipelineUtils.IdToObject(childResult.InstanceId.Value) as GameObject;
|
||||
|
||||
Assert.AreEqual(parent.transform, child.transform.parent, "Child should be parented");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetTransform_PrimitiveChannels_Apply()
|
||||
{
|
||||
var go = Track(new GameObject("Xform_CLI192"));
|
||||
|
||||
GameObjectCommands.SetTransform(RefTo(go),
|
||||
position: new[] { 1f, 2f, 3f },
|
||||
rotation: new[] { 0f, 90f, 0f },
|
||||
scale: new[] { 2f, 2f, 2f });
|
||||
|
||||
Assert.AreEqual(new Vector3(1, 2, 3), go.transform.localPosition);
|
||||
Assert.AreEqual(new Vector3(2, 2, 2), go.transform.localScale);
|
||||
Assert.AreEqual(90f, go.transform.localEulerAngles.y, 0.01f);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetParent_ToNull_DetachesToRoot()
|
||||
{
|
||||
var parent = Track(new GameObject("P_CLI192"));
|
||||
var child = Track(new GameObject("C_CLI192"));
|
||||
child.transform.SetParent(parent.transform);
|
||||
|
||||
GameObjectCommands.SetParent(RefTo(child), null);
|
||||
|
||||
Assert.IsNull(child.transform.parent, "Child should be detached to scene root");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetActive_TogglesState()
|
||||
{
|
||||
var go = Track(new GameObject("Active_CLI192"));
|
||||
GameObjectCommands.SetActive(RefTo(go), false);
|
||||
Assert.IsFalse(go.activeSelf);
|
||||
GameObjectCommands.SetActive(RefTo(go), true);
|
||||
Assert.IsTrue(go.activeSelf);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetLayer_ByIndex_Applies()
|
||||
{
|
||||
var go = Track(new GameObject("Layer_CLI192"));
|
||||
GameObjectCommands.SetLayer(RefTo(go), "5"); // UI layer index
|
||||
Assert.AreEqual(5, go.layer);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetTag_UnknownTag_Throws()
|
||||
{
|
||||
var go = Track(new GameObject("Tag_CLI192"));
|
||||
Assert.Throws<System.ArgumentException>(
|
||||
() => GameObjectCommands.SetTag(RefTo(go), "__definitely_not_a_real_tag__"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Rename_ChangesName()
|
||||
{
|
||||
var go = Track(new GameObject("Old_CLI192"));
|
||||
GameObjectCommands.RenameGameObject(RefTo(go), "New_CLI192");
|
||||
Assert.AreEqual("New_CLI192", go.name);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FindGameObjects_ByNameAndType_Filters()
|
||||
{
|
||||
var go = Track(new GameObject("Findable_CLI192"));
|
||||
go.AddComponent<Rigidbody>();
|
||||
|
||||
var result = GameObjectCommands.FindGameObjects(name: "Findable_CLI192", type: "Rigidbody");
|
||||
|
||||
Assert.AreEqual(1, result.Count);
|
||||
Assert.AreEqual(PipelineUtils.GetObjectId(go), result.GameObjects[0].InstanceId);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Delete_RemovesFromScene()
|
||||
{
|
||||
var go = new GameObject("Doomed_CLI192");
|
||||
var id = PipelineUtils.GetObjectId(go);
|
||||
GameObjectCommands.DeleteGameObject(new ObjectRef { InstanceId = id });
|
||||
|
||||
Assert.IsNull(PipelineUtils.IdToObject(id), "GameObject should be destroyed");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetTransform_IsUndone()
|
||||
{
|
||||
var go = Track(new GameObject("Undo_CLI192"));
|
||||
go.transform.localPosition = Vector3.zero;
|
||||
|
||||
GameObjectCommands.SetTransform(RefTo(go), position: new[] { 5f, 5f, 5f });
|
||||
Assert.AreEqual(new Vector3(5, 5, 5), go.transform.localPosition);
|
||||
|
||||
Undo.PerformUndo();
|
||||
Assert.AreEqual(Vector3.zero, go.transform.localPosition, "Undo should revert the transform change");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ViaClient
|
||||
|
||||
[Test]
|
||||
public void CreateGameObject_ViaClient_Succeeds()
|
||||
{
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("create_gameobject", new { name = "ViaClient_CLI192", primitive = "sphere" });
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, $"create_gameobject should succeed: {response.Error}");
|
||||
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
|
||||
Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field");
|
||||
|
||||
var instanceId = response.JsonResponse["result"]?["instanceId"]?.ToObject<ObjectId?>();
|
||||
Assert.IsTrue(instanceId.HasValue, "Result should carry an instanceId");
|
||||
var go = PipelineUtils.IdToObject(instanceId.Value) as GameObject;
|
||||
Track(go);
|
||||
Assert.IsNotNull(go, "Created object should be resolvable");
|
||||
Assert.IsNotNull(go.GetComponent<SphereCollider>(), "Sphere primitive should have a SphereCollider");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FindGameObjects_ViaClient_ReturnsMatches()
|
||||
{
|
||||
var go = Track(new GameObject("ViaFind_CLI192"));
|
||||
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("find_gameobjects", new { name = "ViaFind_CLI192" });
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, $"find_gameobjects should succeed: {response.Error}");
|
||||
var count = response.JsonResponse["result"]?["count"]?.ToObject<int>();
|
||||
Assert.AreEqual(1, count, "Should find exactly the one named GameObject");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 661e4b429e2643e185927cf6459c3e25
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
using System.Collections;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Compilation;
|
||||
using Unity.Pipeline.HotReload;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the new assemblyDir parameter functionality in hot reload compilation.
|
||||
/// Verifies that assemblies are saved to disk when assemblyDir is specified, and remain in-memory when not specified.
|
||||
/// </summary>
|
||||
public class HotReloadAssemblyDirectoryTests
|
||||
{
|
||||
private string testHotReloadDir;
|
||||
private string testAssemblyDir;
|
||||
private string validHotReloadFile;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
// Write source files OUTSIDE Assets/ (project-root Temp/, ignored by Unity) so they are
|
||||
// never imported/compiled by the editor → no domain reload that would kill the live
|
||||
// server. Absolute paths are passed to the compiler (GetHotReloadPath returns them as-is).
|
||||
testHotReloadDir = Path.GetFullPath(Path.Combine("Temp", "PipelineHotReloadTests", "AssemblyDirTests"));
|
||||
testAssemblyDir = Path.Combine("TestAssemblies", "HotReload");
|
||||
Directory.CreateDirectory(testHotReloadDir);
|
||||
Directory.CreateDirectory(testAssemblyDir);
|
||||
|
||||
validHotReloadFile = Path.Combine(testHotReloadDir, "AssemblyDirTest.cs");
|
||||
|
||||
// Create a valid hot reload file
|
||||
File.WriteAllText(validHotReloadFile, @"
|
||||
using Unity.Pipeline.HotReload;
|
||||
using UnityEngine;
|
||||
|
||||
// Target class with reloadable method
|
||||
public class AssemblyDirTestClass
|
||||
{
|
||||
public int value = 42;
|
||||
|
||||
[HotReloadWithOverrides(Id = ""AssemblyDirTest.TestMethod"")]
|
||||
public void TestMethod()
|
||||
{
|
||||
value = 123;
|
||||
}
|
||||
}
|
||||
|
||||
// Hot reload override
|
||||
public static class AssemblyDirTestHotReload
|
||||
{
|
||||
[HotReloadOverrideMethod(""AssemblyDirTest.TestMethod"")]
|
||||
public static void TestMethod(AssemblyDirTestClass instance)
|
||||
{
|
||||
instance.value = 999;
|
||||
Debug.Log(""Assembly directory test hot reload method executed"");
|
||||
}
|
||||
}");
|
||||
|
||||
// Clear state
|
||||
HotReloadRegistry.ClearAllForTesting();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
// Clean up test files and directories
|
||||
if (Directory.Exists(testHotReloadDir))
|
||||
{
|
||||
Directory.Delete(testHotReloadDir, true);
|
||||
}
|
||||
if (Directory.Exists(testAssemblyDir))
|
||||
{
|
||||
Directory.Delete(testAssemblyDir, true);
|
||||
}
|
||||
if (Directory.Exists("TestAssemblies"))
|
||||
{
|
||||
Directory.Delete("TestAssemblies", true);
|
||||
}
|
||||
|
||||
// Clean up hot reload state
|
||||
HotReloadRegistry.ClearAllForTesting();
|
||||
HotReloadCompiler.CleanupHotReloadDlls(); // Default cleanup
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CompileAndApplyAsync_WithoutAssemblyDir_AssemblyStaysInMemory()
|
||||
{
|
||||
// Act - Compile without assemblyDir (should remain in-memory)
|
||||
var result = await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(result.IsSuccess, $"Compilation should succeed. Error: {result.Error}");
|
||||
Assert.IsNotEmpty(result.AssemblyName, "Should have assembly name");
|
||||
|
||||
// The assembly should not exist in our test directory
|
||||
var expectedDiskPath = Path.Combine(testAssemblyDir, $"{result.AssemblyName}.dll");
|
||||
Assert.IsFalse(File.Exists(expectedDiskPath), "Assembly should not be saved to test directory");
|
||||
|
||||
// But hot reload should still work (assembly is in memory)
|
||||
var stats = HotReloadRegistry.GetStats();
|
||||
Assert.Greater(stats.ActiveOverrideCount, 0, "Should have registered hot reload methods");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CompileAndApplyAsync_WithAssemblyDir_AssemblyIsSavedToDisk()
|
||||
{
|
||||
// Act - Compile with assemblyDir (should save to disk)
|
||||
var result = await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile, testAssemblyDir);
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(result.IsSuccess, $"Compilation should succeed. Error: {result.Error}");
|
||||
Assert.IsNotEmpty(result.AssemblyName, "Should have assembly name");
|
||||
|
||||
// The assembly should exist on disk
|
||||
var expectedDiskPath = Path.Combine(testAssemblyDir, $"{result.AssemblyName}.dll");
|
||||
Assert.IsTrue(File.Exists(expectedDiskPath), $"Assembly should be saved to disk: {expectedDiskPath}");
|
||||
|
||||
// Verify file size is reasonable (should be a valid assembly)
|
||||
var fileInfo = new FileInfo(expectedDiskPath);
|
||||
Assert.Greater(fileInfo.Length, 1000, "Assembly file should have reasonable size");
|
||||
|
||||
// Hot reload should still work (assembly is loaded from memory)
|
||||
var stats = HotReloadRegistry.GetStats();
|
||||
Assert.Greater(stats.ActiveOverrideCount, 0, "Should have registered hot reload methods");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CompileAndApplyAsync_WithAssemblyDir_MultipleVersionsCreateSeparateFiles()
|
||||
{
|
||||
// Expected errors: signature mismatches for versions 2+ due to cross-assembly type incompatibility
|
||||
LogAssert.Expect(LogType.Error, "HotReload: Signature mismatch for 'AssemblyDirTest.TestMethod'. Override method must have instance parameter as first argument.");
|
||||
LogAssert.Expect(LogType.Error, "HotReload: Signature mismatch for 'AssemblyDirTest.TestMethod'. Override method must have instance parameter as first argument.");
|
||||
|
||||
// Act - Compile same file multiple times to create versioned assemblies
|
||||
var result1 = await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile, testAssemblyDir);
|
||||
var result2 = await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile, testAssemblyDir);
|
||||
var result3 = await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile, testAssemblyDir);
|
||||
|
||||
// Assert - All compilations should succeed
|
||||
Assert.IsTrue(result1.IsSuccess, "First compilation should succeed");
|
||||
Assert.IsTrue(result2.IsSuccess, "Second compilation should succeed");
|
||||
Assert.IsTrue(result3.IsSuccess, "Third compilation should succeed");
|
||||
|
||||
// Assert - Each should have different version numbers
|
||||
Assert.That(result1.AssemblyName, Does.Contain("_001"), "First version should be 001");
|
||||
Assert.That(result2.AssemblyName, Does.Contain("_002"), "Second version should be 002");
|
||||
Assert.That(result3.AssemblyName, Does.Contain("_003"), "Third version should be 003");
|
||||
|
||||
// Assert - All versions should exist on disk
|
||||
var diskPath1 = Path.Combine(testAssemblyDir, $"{result1.AssemblyName}.dll");
|
||||
var diskPath2 = Path.Combine(testAssemblyDir, $"{result2.AssemblyName}.dll");
|
||||
var diskPath3 = Path.Combine(testAssemblyDir, $"{result3.AssemblyName}.dll");
|
||||
|
||||
Assert.IsTrue(File.Exists(diskPath1), $"Version 1 should exist: {diskPath1}");
|
||||
Assert.IsTrue(File.Exists(diskPath2), $"Version 2 should exist: {diskPath2}");
|
||||
Assert.IsTrue(File.Exists(diskPath3), $"Version 3 should exist: {diskPath3}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CleanupHotReloadDlls_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 = HotReloadCompiler.CleanupHotReloadDlls(testAssemblyDir);
|
||||
|
||||
// Assert - Cleanup should succeed
|
||||
Assert.IsTrue(result.Success, $"Cleanup should succeed. Message: {result.Message}");
|
||||
|
||||
// Note: Cleanup only removes older versions, keeping the latest, so some files may remain
|
||||
// The important thing is that the cleanup operated on the correct directory
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7e24a4fd1f3d4c04e93baa986056cfbc
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Compilation;
|
||||
using Unity.Pipeline.HotReload;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests specifically for HotReloadMethod attribute compilation and reflection.
|
||||
/// Helps diagnose issues with attribute detection in compiled assemblies.
|
||||
/// </summary>
|
||||
public class HotReloadAttributeTests
|
||||
{
|
||||
[Test]
|
||||
public void HotReloadOverrideMethodAttribute_DirectUsage_CanBeReflected()
|
||||
{
|
||||
// Test that the attribute can be found via reflection on a directly compiled class
|
||||
|
||||
var method = typeof(TestHotReloadClass).GetMethod("TestOverrideMethod");
|
||||
Assert.IsNotNull(method, "Test method should exist");
|
||||
|
||||
var attribute = method.GetCustomAttribute<HotReloadOverrideMethodAttribute>();
|
||||
Assert.IsNotNull(attribute, "HotReloadMethod attribute should be found");
|
||||
Assert.AreEqual("TestTarget.TestMethod", attribute.TargetMethodId, "Target method ID should match");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RoslynCompilationService_WithHotReloadAttribute_CompilesAndReflects()
|
||||
{
|
||||
// Test that RoslynCompilationService can compile code with HotReloadMethod attribute
|
||||
// and the attribute can be reflected from the compiled assembly
|
||||
|
||||
var sourceCode = @"
|
||||
using Unity.Pipeline.HotReload;
|
||||
using UnityEngine;
|
||||
|
||||
public static class CompiledHotReloadClass
|
||||
{
|
||||
[HotReloadOverrideMethod(""CompiledTarget.CompiledMethod"")]
|
||||
public static void CompiledOverrideMethod(object instance)
|
||||
{
|
||||
Debug.Log(""Compiled hot reload method"");
|
||||
}
|
||||
}";
|
||||
|
||||
var request = new CompilationRequest
|
||||
{
|
||||
SourceCode = sourceCode,
|
||||
AssemblyName = "HotReloadAttributeTest",
|
||||
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("CompiledHotReloadClass");
|
||||
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 hotReloadAttribute = compiledMethod.GetCustomAttribute<HotReloadOverrideMethodAttribute>();
|
||||
if (hotReloadAttribute == null)
|
||||
{
|
||||
// Try to find by name
|
||||
var attributeByName = customAttributes.FirstOrDefault(a => a.GetType().Name == "HotReloadOverrideMethodAttribute");
|
||||
|
||||
if (attributeByName != null)
|
||||
{
|
||||
hotReloadAttribute = attributeByName as HotReloadOverrideMethodAttribute;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.IsNotNull(hotReloadAttribute, "HotReloadMethod attribute should be found on compiled method");
|
||||
Assert.AreEqual("CompiledTarget.CompiledMethod", hotReloadAttribute.TargetMethodId, "Target method ID should match");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MetadataReferences_IncludeUnityPipeline_CanFindHotReloadTypes()
|
||||
{
|
||||
// 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 HotReloadMethod attribute for direct testing.
|
||||
/// </summary>
|
||||
public static class TestHotReloadClass
|
||||
{
|
||||
[HotReloadOverrideMethod("TestTarget.TestMethod")]
|
||||
public static void TestOverrideMethod(object instance)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1b46f0c74679e854288a8933221a317a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
using System.Collections;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.HotReload;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Basic tests for hot reload Pattern A method override functionality.
|
||||
/// Tests the end-to-end flow: mark method -> create override -> compile -> verify behavior.
|
||||
/// </summary>
|
||||
public class HotReloadBasicTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Test class with hot reloadable methods for testing.
|
||||
/// </summary>
|
||||
public class TestComponent : MonoBehaviour
|
||||
{
|
||||
public int lastCalculatedValue;
|
||||
public string lastMessage;
|
||||
|
||||
[HotReloadWithOverrides]
|
||||
public void UpdateValue()
|
||||
{
|
||||
lastCalculatedValue = 10; // Original behavior
|
||||
lastMessage = "Original UpdateValue called";
|
||||
}
|
||||
|
||||
[HotReloadWithOverrides(Id = "CustomCalculation")]
|
||||
public int CalculateScore(int input)
|
||||
{
|
||||
return input * 2; // Original calculation
|
||||
}
|
||||
}
|
||||
|
||||
private TestComponent testComponent;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
// Create test component
|
||||
var go = new GameObject("HotReloadTestObject");
|
||||
testComponent = go.AddComponent<TestComponent>();
|
||||
|
||||
// Clear any existing hot reload state (including reloadable methods for test isolation)
|
||||
HotReloadRegistry.ClearAllForTesting();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
if (testComponent != null && testComponent.gameObject != null)
|
||||
{
|
||||
Object.DestroyImmediate(testComponent.gameObject);
|
||||
}
|
||||
|
||||
// Clean up hot reload state
|
||||
HotReloadRegistry.ClearAllForTesting();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HotReloadRegistry_RegisterReloadableMethod_Success()
|
||||
{
|
||||
// Arrange
|
||||
var method = typeof(TestComponent).GetMethod("UpdateValue");
|
||||
var attribute = new HotReloadWithOverridesAttribute();
|
||||
|
||||
// Act
|
||||
HotReloadRegistry.RegisterReloadableMethod(method, attribute);
|
||||
|
||||
// Assert
|
||||
var stats = HotReloadRegistry.GetStats();
|
||||
Assert.That(stats.ReloadableMethodCount, Is.EqualTo(1));
|
||||
Assert.That(stats.ReloadableMethodIds.Contains("TestComponent.UpdateValue"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HotReloadRegistry_RegisterReloadableMethodWithCustomId_Success()
|
||||
{
|
||||
// Arrange
|
||||
var method = typeof(TestComponent).GetMethod("CalculateScore");
|
||||
var attribute = new HotReloadWithOverridesAttribute { Id = "CustomCalculation" };
|
||||
|
||||
// Act
|
||||
HotReloadRegistry.RegisterReloadableMethod(method, attribute);
|
||||
|
||||
// Assert
|
||||
var stats = HotReloadRegistry.GetStats();
|
||||
Assert.That(stats.ReloadableMethodCount, Is.EqualTo(1));
|
||||
Assert.That(stats.ReloadableMethodIds.Contains("CustomCalculation"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HotReloadRegistry_TryInvokeHotReload_NoOverrideReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var method = typeof(TestComponent).GetMethod("UpdateValue");
|
||||
var attribute = new HotReloadWithOverridesAttribute();
|
||||
HotReloadRegistry.RegisterReloadableMethod(method, attribute);
|
||||
|
||||
// Act
|
||||
var result = HotReloadRegistry.TryInvokeHotReload("TestComponent.UpdateValue", testComponent);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.False, "Should return false when no hot reload override is registered");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HotReloadRegistry_GetStats_ReturnsCorrectCounts()
|
||||
{
|
||||
// Arrange - register multiple methods
|
||||
var updateMethod = typeof(TestComponent).GetMethod("UpdateValue");
|
||||
var calculateMethod = typeof(TestComponent).GetMethod("CalculateScore");
|
||||
|
||||
HotReloadRegistry.RegisterReloadableMethod(updateMethod, new HotReloadWithOverridesAttribute());
|
||||
HotReloadRegistry.RegisterReloadableMethod(calculateMethod, new HotReloadWithOverridesAttribute { Id = "CustomCalculation" });
|
||||
|
||||
// Act
|
||||
var stats = HotReloadRegistry.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 HotReloadRegistry_ClearAllOverrides_ClearsAllState()
|
||||
{
|
||||
// Arrange - register some methods and mock some overrides
|
||||
var method = typeof(TestComponent).GetMethod("UpdateValue");
|
||||
HotReloadRegistry.RegisterReloadableMethod(method, new HotReloadWithOverridesAttribute());
|
||||
|
||||
// Act
|
||||
HotReloadRegistry.ClearAllOverrides();
|
||||
|
||||
// Assert
|
||||
var stats = HotReloadRegistry.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 HotReloadRegistry_MainThreadDispatch_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var method = typeof(TestComponent).GetMethod("UpdateValue");
|
||||
var attribute = new HotReloadWithOverridesAttribute { RequireMainThread = true };
|
||||
HotReloadRegistry.RegisterReloadableMethod(method, attribute);
|
||||
|
||||
// Act - test that method can be invoked (even without override, for thread safety)
|
||||
var result = HotReloadRegistry.TryInvokeHotReload("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
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 96d537e6f81b9f140a08fabafd10500e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
using System.Collections;
|
||||
using System.IO;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Compilation;
|
||||
using Unity.Pipeline.HotReload;
|
||||
using Unity.Pipeline.Runtime.Commands;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Integration tests for hot reload CLI commands.
|
||||
/// Tests the complete workflow: CLI commands → compiler → registry → runtime behavior.
|
||||
/// </summary>
|
||||
public class HotReloadCommandTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
// Clear hot reload state
|
||||
HotReloadRegistry.ClearAllForTesting();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
HotReloadRegistry.ClearAllForTesting();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HotReloadStatus_ReturnsStats()
|
||||
{
|
||||
// Arrange - register a test method
|
||||
var method = typeof(SimpleTestClass).GetMethod("TestMethod");
|
||||
HotReloadRegistry.RegisterReloadableMethod(method, new HotReloadWithOverridesAttribute());
|
||||
|
||||
// Act
|
||||
var result = HotReloadCommands.HotReloadStatus();
|
||||
|
||||
// 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 = HotReloadCommands.ReloadFileOverride("");
|
||||
|
||||
// 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_WithInvalidTimeout_ReturnsBadRequest()
|
||||
{
|
||||
// Act
|
||||
var result = HotReloadCommands.ReloadFileOverride("test.cs", timeout: -1);
|
||||
|
||||
// Assert
|
||||
Assert.That(result.Success, Is.False);
|
||||
Assert.That(result.Error, Is.EqualTo("Bad Request"));
|
||||
Assert.That(result.ErrorDetails, Contains.Substring("Timeout must be between"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReloadFile_WithNonExistentFile_ReturnsFileNotFound()
|
||||
{
|
||||
// Act
|
||||
var result = HotReloadCommands.ReloadFileOverride("NonExistentFile.cs");
|
||||
|
||||
// Assert
|
||||
Assert.That(result.Success, Is.False);
|
||||
Assert.That(result.Error, Is.EqualTo("File Not Found"));
|
||||
Assert.That(result.ErrorDetails, Contains.Substring("not found"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReloadFile_FileWithoutOverrides_ReturnsInvalidOverrideFile()
|
||||
{
|
||||
// A plain gameplay file (no [HotReloadOverrideMethod]) is not an override file.
|
||||
var path = Path.GetFullPath(Path.Combine("Temp", "PipelineHotReloadTests", "CommandTests", "NoOverrides.cs"));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path));
|
||||
File.WriteAllText(path, "using UnityEngine;\npublic class Plain : MonoBehaviour { void Update() { } }");
|
||||
|
||||
try
|
||||
{
|
||||
var result = HotReloadCommands.ReloadFileOverride(path);
|
||||
|
||||
Assert.That(result.Success, Is.False);
|
||||
Assert.That(result.Error, Is.EqualTo("Invalid Override File"));
|
||||
Assert.That(result.ErrorDetails, Contains.Substring("No [HotReloadOverrideMethod]"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReloadFile_OverrideRedeclaresTargetType_ReturnsInvalidOverrideFile()
|
||||
{
|
||||
// The override targets Boss but the file also declares Boss -> redeclaration is rejected.
|
||||
var path = Path.GetFullPath(Path.Combine("Temp", "PipelineHotReloadTests", "CommandTests", "SameFile.cs"));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path));
|
||||
File.WriteAllText(path, @"
|
||||
using Unity.Pipeline.HotReload;
|
||||
using UnityEngine;
|
||||
|
||||
public class Boss : MonoBehaviour
|
||||
{
|
||||
[HotReloadOverrideMethod(""Boss.Update"")]
|
||||
public static void Tweaked(Boss instance) { }
|
||||
}");
|
||||
|
||||
try
|
||||
{
|
||||
var result = HotReloadCommands.ReloadFileOverride(path);
|
||||
|
||||
Assert.That(result.Success, Is.False);
|
||||
Assert.That(result.Error, Is.EqualTo("Invalid Override File"));
|
||||
Assert.That(result.ErrorDetails, Contains.Substring("Boss"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReloadFileInPlace_NonExistentFile_ReturnsFileNotFound()
|
||||
{
|
||||
var result = HotReloadCommands.ReloadFile("NonExistentInPlaceFile.cs");
|
||||
|
||||
Assert.That(result.Success, Is.False);
|
||||
Assert.That(result.Error, Is.EqualTo("File Not Found"));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ReloadFile_WithValidFile_CompilationProcess()
|
||||
{
|
||||
// This test would require a valid hot reload file to exist
|
||||
// For now, we just test that the command structure works
|
||||
|
||||
// Arrange - create a simple test file OUTSIDE Assets/ (project-root Temp/, ignored by
|
||||
// Unity) so it isn't imported/compiled → no domain reload that would kill the live server.
|
||||
// Absolute path is passed to ReloadFileOverride, whose GetHotReloadPath returns it as-is.
|
||||
var testFilePath = Path.GetFullPath(Path.Combine("Temp", "PipelineHotReloadTests", "CommandTests", "TestCommandFile.cs"));
|
||||
var testFileContent = @"
|
||||
using Unity.Pipeline.HotReload;
|
||||
using UnityEngine;
|
||||
|
||||
public static class TestCommandFile
|
||||
{
|
||||
[HotReloadOverrideMethod(""SimpleTestClass.TestMethod"")]
|
||||
public static void TestMethod(SimpleTestClass instance)
|
||||
{
|
||||
instance.value = 999;
|
||||
}
|
||||
}";
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(testFilePath));
|
||||
File.WriteAllText(testFilePath, testFileContent);
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = HotReloadCommands.ReloadFileOverride(testFilePath);
|
||||
|
||||
// Assert - compilation should be attempted (may fail due to missing references, but structure should work)
|
||||
Assert.That(result, Is.Not.Null);
|
||||
// Note: This test mainly verifies the command structure and error handling
|
||||
// Full compilation tests would require proper assembly setup
|
||||
|
||||
yield return null; // Unity test requirement
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Cleanup
|
||||
if (File.Exists(testFilePath))
|
||||
{
|
||||
File.Delete(testFilePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HotReloadResponse_Success_CreatesCorrectResponse()
|
||||
{
|
||||
// Act
|
||||
var response = HotReloadResponse.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 HotReloadResponse_Failure_CreatesCorrectResponse()
|
||||
{
|
||||
// Act
|
||||
var response = HotReloadResponse.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 hot reload testing.
|
||||
/// </summary>
|
||||
public class SimpleTestClass
|
||||
{
|
||||
public int value;
|
||||
|
||||
public void TestMethod()
|
||||
{
|
||||
value = 42;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3f81555b71859b94c8349361be0d1a9b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+362
@@ -0,0 +1,362 @@
|
||||
using System.Collections;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Compilation;
|
||||
using Unity.Pipeline.HotReload;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for HotReloadCompiler to verify refactoring, deadlock fix, and core functionality.
|
||||
/// Tests the complete workflow: file reading → compilation → method registration.
|
||||
/// </summary>
|
||||
public class HotReloadCompilerTests
|
||||
{
|
||||
private string testHotReloadDir;
|
||||
private string validHotReloadFile;
|
||||
private string invalidHotReloadFile;
|
||||
private string emptyHotReloadFile;
|
||||
private int m_MainThreadId;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
// Write test source files OUTSIDE Assets/ (project-root Temp/, which Unity ignores) so
|
||||
// they are never imported/compiled by the editor — importing a .cs under Assets/ triggers
|
||||
// a domain reload that tears down the live pipeline server. Absolute paths are passed to
|
||||
// the compiler, whose GetHotReloadPath returns rooted paths as-is (no Assets/ prefix).
|
||||
testHotReloadDir = Path.GetFullPath(Path.Combine("Temp", "PipelineHotReloadTests", "CompilerTests"));
|
||||
Directory.CreateDirectory(testHotReloadDir);
|
||||
|
||||
validHotReloadFile = Path.Combine(testHotReloadDir, "ValidTest.cs");
|
||||
invalidHotReloadFile = Path.Combine(testHotReloadDir, "InvalidTest.cs");
|
||||
emptyHotReloadFile = Path.Combine(testHotReloadDir, "EmptyTest.cs");
|
||||
|
||||
// Create valid hot reload file with both reloadable target and hot reload override
|
||||
File.WriteAllText(validHotReloadFile, @"
|
||||
using Unity.Pipeline.HotReload;
|
||||
using UnityEngine;
|
||||
|
||||
// Target class with reloadable method
|
||||
public class TestClass
|
||||
{
|
||||
public int value = 42;
|
||||
|
||||
[HotReloadWithOverrides(Id = ""TestClass.TestMethod"")]
|
||||
public void TestMethod()
|
||||
{
|
||||
value = 123;
|
||||
}
|
||||
}
|
||||
|
||||
// Hot reload override
|
||||
public static class ValidTestHotReload
|
||||
{
|
||||
[HotReloadOverrideMethod(""TestClass.TestMethod"")]
|
||||
public static void TestMethod(TestClass instance)
|
||||
{
|
||||
instance.value = 999;
|
||||
Debug.Log(""Hot reload method executed"");
|
||||
}
|
||||
}");
|
||||
|
||||
// Create invalid hot reload file (syntax error)
|
||||
File.WriteAllText(invalidHotReloadFile, @"
|
||||
using Unity.Pipeline.HotReload;
|
||||
|
||||
// Need to define TestClass since we're not wrapping code anymore
|
||||
public class TestClass
|
||||
{
|
||||
public int value = 42;
|
||||
|
||||
[HotReloadWithOverrides(Id = ""TestClass.TestMethod"")]
|
||||
public void TestMethod()
|
||||
{
|
||||
value = 123;
|
||||
}
|
||||
}
|
||||
|
||||
public static class InvalidTestHotReload
|
||||
{
|
||||
[HotReloadOverrideMethod(""TestClass.TestMethod"")]
|
||||
public static void TestMethod(TestClass instance)
|
||||
{
|
||||
instance.value = 999 +; // Syntax error
|
||||
}
|
||||
}");
|
||||
|
||||
// Create empty file
|
||||
File.WriteAllText(emptyHotReloadFile, "");
|
||||
|
||||
// Clear hot reload state
|
||||
HotReloadRegistry.ClearAllForTesting();
|
||||
|
||||
// Capture the main thread so tests can assert thread context.
|
||||
m_MainThreadId = Thread.CurrentThread.ManagedThreadId;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
// Clean up test files
|
||||
if (Directory.Exists(testHotReloadDir))
|
||||
{
|
||||
Directory.Delete(testHotReloadDir, true);
|
||||
}
|
||||
|
||||
// Clean up hot reload state and temp files
|
||||
HotReloadRegistry.ClearAllForTesting();
|
||||
HotReloadCompiler.CleanupHotReloadDlls();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CompileAndApplyAsync_FromMainThread_RunsSynchronously()
|
||||
{
|
||||
// Arrange - We're on main thread
|
||||
Assert.AreEqual(m_MainThreadId, Thread.CurrentThread.ManagedThreadId, "Test should run on main thread");
|
||||
|
||||
// Act - This should not deadlock (the main fix being tested)
|
||||
var task = HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
|
||||
|
||||
// Assert - Should complete synchronously without blocking
|
||||
Assert.IsTrue(task.IsCompleted, "Should complete synchronously on main thread to avoid deadlock");
|
||||
|
||||
var result = task.Result;
|
||||
Assert.IsTrue(result.IsSuccess, $"Valid hot reload should succeed. Error: {result.Error}");
|
||||
Assert.IsNotEmpty(result.AssemblyName, "Should have assembly name");
|
||||
Assert.Greater(result.ExecutionTimeMs, 0, "Should record execution time");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CompileAndApplyAsync_FromBackgroundThread_UsesAsyncPath()
|
||||
{
|
||||
// Arrange - Track thread IDs
|
||||
var mainThreadId = Thread.CurrentThread.ManagedThreadId;
|
||||
var backgroundThreadId = -1;
|
||||
HotReloadCompileResult result = null;
|
||||
|
||||
// Act - Run from background thread
|
||||
await Task.Run(async () =>
|
||||
{
|
||||
backgroundThreadId = Thread.CurrentThread.ManagedThreadId;
|
||||
Assert.AreNotEqual(mainThreadId, backgroundThreadId, "Should be on different thread");
|
||||
Assert.AreNotEqual(m_MainThreadId, Thread.CurrentThread.ManagedThreadId, "Should not be on main thread");
|
||||
|
||||
result = await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(result, "Should get result");
|
||||
Assert.IsTrue(result.IsSuccess, $"Background compilation should succeed. Error: {result.Error}");
|
||||
Assert.IsNotEmpty(result.AssemblyName, "Should have assembly name");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CompileAndApplyAsync_ValidFile_RegistersHotReloadMethods()
|
||||
{
|
||||
// Get initial stats for comparison
|
||||
var initialStats = HotReloadRegistry.GetStats();
|
||||
|
||||
// Act
|
||||
var result = await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
|
||||
|
||||
// Assert compilation success
|
||||
Assert.IsTrue(result.IsSuccess, $"Should succeed. Error: {result.Error}");
|
||||
|
||||
Assert.Greater(result.RegisteredMethods.Count, 0, "Should register at least one method");
|
||||
Assert.Contains("TestClass.TestMethod", result.RegisteredMethods, "Should register the specified target method");
|
||||
|
||||
// Get final stats and compare
|
||||
var finalStats = HotReloadRegistry.GetStats();
|
||||
|
||||
// Verify method was registered in registry
|
||||
Assert.Greater(finalStats.ActiveOverrideCount, initialStats.ActiveOverrideCount, $"Active override count should increase from {initialStats.ActiveOverrideCount} to more than {initialStats.ActiveOverrideCount}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CompileAndApplyAsync_InvalidSyntax_ReturnsCompilationError()
|
||||
{
|
||||
// Act
|
||||
var result = await HotReloadCompiler.CompileAndApplyAsync(invalidHotReloadFile);
|
||||
|
||||
// Assert
|
||||
Assert.IsFalse(result.IsSuccess, "Invalid syntax should fail compilation");
|
||||
Assert.AreEqual("Compilation Failed", result.Error, "Should return compilation failed error");
|
||||
Assert.IsNotNull(result.Diagnostics, "Should have diagnostics");
|
||||
Assert.Greater(result.Diagnostics.Count, 0, "Should have compilation diagnostics");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CompileAndApplyAsync_EmptyFile_ReturnsEmptyFileError()
|
||||
{
|
||||
// Act
|
||||
var result = await HotReloadCompiler.CompileAndApplyAsync(emptyHotReloadFile);
|
||||
|
||||
// Assert
|
||||
Assert.IsFalse(result.IsSuccess, "Empty file should fail");
|
||||
Assert.AreEqual("Empty File", result.Error, "Should return empty file error");
|
||||
Assert.That(result.ErrorDetails, Does.Contain("empty"), "Should mention file is empty");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CompileAndApplyAsync_FileNotFound_ReturnsFileNotFoundError()
|
||||
{
|
||||
// Act
|
||||
var result = await HotReloadCompiler.CompileAndApplyAsync("NonExistentFile.cs");
|
||||
|
||||
// Assert
|
||||
Assert.IsFalse(result.IsSuccess, "Non-existent file should fail");
|
||||
Assert.AreEqual("File Not Found", result.Error, "Should return file not found error");
|
||||
Assert.That(result.ErrorDetails, Does.Contain("not found"), "Should mention file not found");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CompileAndApplyAsync_VersionTracking_GeneratesIncrementingVersions()
|
||||
{
|
||||
// Expected errors: signature mismatches for versions 2+ due to cross-assembly type incompatibility
|
||||
LogAssert.Expect(LogType.Error, "HotReload: Signature mismatch for 'TestClass.TestMethod'. Override method must have instance parameter as first argument.");
|
||||
LogAssert.Expect(LogType.Error, "HotReload: Signature mismatch for 'TestClass.TestMethod'. Override method must have instance parameter as first argument.");
|
||||
|
||||
// Act - Compile the same file multiple times
|
||||
var result1 = await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
|
||||
var result2 = await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
|
||||
var result3 = await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
|
||||
|
||||
// Assert - Each compilation should have incrementing version numbers
|
||||
Assert.IsTrue(result1.IsSuccess, "First compilation should succeed");
|
||||
Assert.IsTrue(result2.IsSuccess, "Second compilation should succeed");
|
||||
Assert.IsTrue(result3.IsSuccess, "Third compilation should succeed");
|
||||
|
||||
// Assembly names should contain version numbers
|
||||
Assert.That(result1.AssemblyName, Does.Contain("_001"), "First version should be 001");
|
||||
Assert.That(result2.AssemblyName, Does.Contain("_002"), "Second version should be 002");
|
||||
Assert.That(result3.AssemblyName, Does.Contain("_003"), "Third version should be 003");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CleanupHotReloadDlls_WithNoFiles_SucceedsGracefully()
|
||||
{
|
||||
// Act
|
||||
var result = HotReloadCompiler.CleanupHotReloadDlls();
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(result.Success, "Cleanup should succeed even when no files exist");
|
||||
Assert.AreEqual(0, result.DeletedFiles.Count, "Should not delete any files");
|
||||
Assert.That(result.Message, Does.Contain("No hot reload temp directory").Or.Contain("0 old DLL versions"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CleanupHotReloadDlls_AfterCompilations_CleansUpOldVersions()
|
||||
{
|
||||
// Expected errors: signature mismatches for versions 2+ due to cross-assembly type incompatibility
|
||||
LogAssert.Expect(LogType.Error, "HotReload: Signature mismatch for 'TestClass.TestMethod'. Override method must have instance parameter as first argument.");
|
||||
LogAssert.Expect(LogType.Error, "HotReload: Signature mismatch for 'TestClass.TestMethod'. Override method must have instance parameter as first argument.");
|
||||
|
||||
// Arrange - Create multiple versions
|
||||
await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
|
||||
await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
|
||||
await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
|
||||
|
||||
// Act - Cleanup
|
||||
var cleanupResult = HotReloadCompiler.CleanupHotReloadDlls();
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(cleanupResult.Success, "Cleanup should succeed");
|
||||
Assert.GreaterOrEqual(cleanupResult.ExecutionTimeMs, 0, "Should record execution time (0ms is acceptable for fast cleanup)");
|
||||
|
||||
// Registry should be cleared
|
||||
var stats = HotReloadRegistry.GetStats();
|
||||
Assert.AreEqual(0, stats.ActiveOverrideCount, "Registry should be cleared after cleanup");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CompileAndApplyAsync_UsesRoslynCompilationService_Integration()
|
||||
{
|
||||
// This test verifies that HotReloadCompiler properly integrates with RoslynCompilationService
|
||||
|
||||
// Act
|
||||
var result = await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
|
||||
|
||||
// Assert - Verify it uses the shared service (indirect verification through successful compilation)
|
||||
Assert.IsTrue(result.IsSuccess, "Should successfully compile using shared RoslynCompilationService");
|
||||
Assert.IsNotEmpty(result.AssemblyName, "Should generate assembly name");
|
||||
|
||||
// The fact that it compiles successfully with the same diagnostics format as EvalCodeCompiler
|
||||
// indicates it's using the shared RoslynCompilationService
|
||||
if (!result.IsSuccess && result.Diagnostics?.Any() == true)
|
||||
{
|
||||
// If there were diagnostics, they should be in the same format as RoslynCompilationService
|
||||
var diagnostic = result.Diagnostics.First();
|
||||
Assert.IsNotEmpty(diagnostic, "Diagnostic should not be empty");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CompileAndApplyAsync_ResolvesPaths_AbsoluteAndRelative()
|
||||
{
|
||||
// Absolute paths are used as-is.
|
||||
var result = await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
|
||||
Assert.IsTrue(result.IsSuccess, $"Should find file by absolute path. Error: {result.Error}");
|
||||
|
||||
// Relative paths resolve against the project root (no special "HotReload" folder).
|
||||
var result2 = await HotReloadCompiler.CompileAndApplyAsync("DoesNotExist.cs");
|
||||
Assert.IsFalse(result2.IsSuccess, "Should fail for non-existent file");
|
||||
Assert.That(result2.ErrorDetails, Does.Contain("DoesNotExist.cs"),
|
||||
"Error should reference the resolved path");
|
||||
Assert.That(result2.ErrorDetails, Does.Not.Contain("HotReload\\DoesNotExist.cs"),
|
||||
"Relative paths should no longer be rooted under a HotReload folder");
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator CompileAndApplyAsync_PerformanceComparison_LogsTiming()
|
||||
{
|
||||
// Expected error: signature mismatch for second compilation due to cross-assembly type incompatibility
|
||||
LogAssert.Expect(LogType.Error, "HotReload: Signature mismatch for 'TestClass.TestMethod'. Override method must have instance parameter as first argument.");
|
||||
|
||||
// Test to observe performance differences between main thread and background execution
|
||||
var filename = validHotReloadFile;
|
||||
|
||||
// Main thread execution
|
||||
var sw1 = System.Diagnostics.Stopwatch.StartNew();
|
||||
var mainThreadTask = HotReloadCompiler.CompileAndApplyAsync(filename);
|
||||
yield return new WaitUntil(() => mainThreadTask.IsCompleted);
|
||||
sw1.Stop();
|
||||
var result1 = mainThreadTask.Result;
|
||||
|
||||
Assert.IsTrue(result1.IsSuccess, "Main thread execution should succeed");
|
||||
|
||||
// Background thread execution
|
||||
var sw2 = System.Diagnostics.Stopwatch.StartNew();
|
||||
HotReloadCompileResult result2 = null;
|
||||
var backgroundTask = Task.Run(async () =>
|
||||
{
|
||||
result2 = await HotReloadCompiler.CompileAndApplyAsync(filename);
|
||||
});
|
||||
|
||||
yield return new WaitUntil(() => backgroundTask.IsCompleted);
|
||||
sw2.Stop();
|
||||
|
||||
Assert.IsNotNull(result2, "Background execution should complete");
|
||||
Assert.IsTrue(result2.IsSuccess, "Background execution should succeed");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CompileAndApplyOnMainThread_DirectCall_WorksSynchronously()
|
||||
{
|
||||
// Test the direct synchronous method (used internally for main thread detection)
|
||||
|
||||
// Act - Call the synchronous version directly
|
||||
var result = HotReloadCompiler.CompileAndApplyOnMainThread(validHotReloadFile);
|
||||
|
||||
// Assert - Should complete synchronously
|
||||
Assert.IsNotNull(result, "Should return result immediately");
|
||||
Assert.IsTrue(result.IsSuccess, $"Direct synchronous call should succeed. Error: {result.Error}");
|
||||
Assert.Greater(result.ExecutionTimeMs, 0, "Should record execution time");
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 22dc4e614651b5b4e8d344bd9857fcf6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.HotReload;
|
||||
using Unity.Pipeline.Runtime.Commands;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Top-level probe type woven by the HotReloadInPlace ILPostProcessor. Must be top-level (not
|
||||
/// nested) so the generated override can reference it by name from a separate assembly.
|
||||
/// </summary>
|
||||
public class InPlaceReloadProbe
|
||||
{
|
||||
public int baseline;
|
||||
public int hotValue;
|
||||
|
||||
[HotReload]
|
||||
public void Compute()
|
||||
{
|
||||
baseline = 1; // original body
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the in-place hot reload workflow, covering all three layers:
|
||||
/// - compile-time IL weaving + runtime dispatch (the ILPostProcessor),
|
||||
/// - the Roslyn semantic-model transform + accessibility validation,
|
||||
/// - the full reload_file pipeline end to end.
|
||||
/// </summary>
|
||||
public class HotReloadInPlaceTests
|
||||
{
|
||||
// -------- weaving fixtures --------
|
||||
|
||||
public class WeaveTarget
|
||||
{
|
||||
public int ticks;
|
||||
public bool overrideRan;
|
||||
|
||||
[HotReload]
|
||||
public void Tick()
|
||||
{
|
||||
ticks++; // original body
|
||||
}
|
||||
}
|
||||
|
||||
public static class WeaveOverrides
|
||||
{
|
||||
[HotReloadOverrideMethod("WeaveTarget.Tick")]
|
||||
public static void Tick(WeaveTarget instance)
|
||||
{
|
||||
instance.overrideRan = true;
|
||||
}
|
||||
}
|
||||
|
||||
private const string PublicSource = @"
|
||||
using UnityEngine;
|
||||
using Unity.Pipeline.HotReload;
|
||||
|
||||
public class Demo : MonoBehaviour
|
||||
{
|
||||
public float speed = 1f;
|
||||
|
||||
[HotReload]
|
||||
void Tick()
|
||||
{
|
||||
var dt = Time.deltaTime;
|
||||
transform.position += Vector3.right * speed * dt;
|
||||
}
|
||||
}";
|
||||
|
||||
private static Dictionary<string, string> Bodies(params string[] names)
|
||||
{
|
||||
var d = new Dictionary<string, string>();
|
||||
foreach (var n in names) d[n] = "";
|
||||
return d;
|
||||
}
|
||||
|
||||
[SetUp]
|
||||
public void Setup() => HotReloadRegistry.ClearAllForTesting();
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => HotReloadRegistry.ClearAllForTesting();
|
||||
|
||||
// -------- weaving + dispatch --------
|
||||
|
||||
[Test]
|
||||
public void NoOverride_RunsOriginalBody()
|
||||
{
|
||||
var t = new WeaveTarget();
|
||||
t.Tick();
|
||||
|
||||
Assert.AreEqual(1, t.ticks, "Original body should run when no override is registered");
|
||||
Assert.IsFalse(t.overrideRan);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WithOverride_WovenDispatchInvokesOverride()
|
||||
{
|
||||
HotReloadRegistry.RegisterReloadableMethod(
|
||||
typeof(WeaveTarget).GetMethod(nameof(WeaveTarget.Tick)),
|
||||
new HotReloadWithOverridesAttribute());
|
||||
|
||||
var registered = HotReloadRegistry.RegisterMethodOverride(
|
||||
typeof(WeaveOverrides).GetMethod(nameof(WeaveOverrides.Tick)),
|
||||
new HotReloadOverrideMethodAttribute("WeaveTarget.Tick"),
|
||||
typeof(WeaveOverrides),
|
||||
out var reason);
|
||||
|
||||
Assert.IsTrue(registered, reason);
|
||||
|
||||
var t = new WeaveTarget();
|
||||
t.Tick();
|
||||
|
||||
Assert.IsTrue(t.overrideRan, "Woven dispatch should have routed Tick() to the override");
|
||||
Assert.AreEqual(0, t.ticks, "Original body should be skipped when the override runs");
|
||||
}
|
||||
|
||||
// -------- semantic transform + accessibility --------
|
||||
|
||||
[Test]
|
||||
public void Transform_QualifiesInstanceMembers_LeavesLocalsAndStatics()
|
||||
{
|
||||
var output = SourceCodeTransformer.TransformMethodBodies(
|
||||
Bodies("Tick"), "Demo", new Dictionary<string, MethodSignatureInfo>(), PublicSource);
|
||||
|
||||
StringAssert.Contains("[HotReloadOverrideMethod(\"Demo.Tick\")]", output);
|
||||
StringAssert.Contains("public static void Tick(Demo instance", output);
|
||||
|
||||
// Instance members are qualified.
|
||||
StringAssert.Contains("instance.transform", output);
|
||||
StringAssert.Contains("instance.speed", output);
|
||||
|
||||
// Statics and locals are left alone.
|
||||
StringAssert.Contains("Time.deltaTime", output);
|
||||
StringAssert.Contains("Vector3.right", output);
|
||||
StringAssert.DoesNotContain("instance.dt", output);
|
||||
StringAssert.DoesNotContain("instance.Time", output);
|
||||
StringAssert.DoesNotContain("instance.Vector3", output);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Transform_WithLineDirectives_MapsBodyToOriginalFile()
|
||||
{
|
||||
// The body's opening brace sits on line 11 of PublicSource.
|
||||
var path = @"C:\proj\Demo.cs";
|
||||
var output = SourceCodeTransformer.TransformMethodBodies(
|
||||
Bodies("Tick"), "Demo", new Dictionary<string, MethodSignatureInfo>(), PublicSource,
|
||||
emitLineDirectives: true, originalFilePath: path);
|
||||
|
||||
// #line maps the body back to the original file (backslashes escaped for the literal),
|
||||
// bracketed by #line hidden so the generated scaffolding isn't attributed to user code.
|
||||
StringAssert.Contains("#line 11 \"C:\\\\proj\\\\Demo.cs\"", output);
|
||||
StringAssert.Contains("#line hidden", output);
|
||||
|
||||
// The instance qualification still happens in this mode.
|
||||
StringAssert.Contains("instance.transform", output);
|
||||
StringAssert.Contains("instance.speed", output);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Transform_WithoutLineDirectives_EmitsNoLineDirectives()
|
||||
{
|
||||
var output = SourceCodeTransformer.TransformMethodBodies(
|
||||
Bodies("Tick"), "Demo", new Dictionary<string, MethodSignatureInfo>(), PublicSource);
|
||||
|
||||
StringAssert.DoesNotContain("#line", output);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Validator_AllowsPublicMembers()
|
||||
{
|
||||
var result = AccessibilityValidator.ValidatePublicAccess(PublicSource, Bodies("Tick"), "Demo");
|
||||
Assert.IsTrue(result.IsValid, result.GetFormattedErrorMessage());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Validator_FlagsPrivateMember()
|
||||
{
|
||||
var source = @"
|
||||
using UnityEngine;
|
||||
using Unity.Pipeline.HotReload;
|
||||
|
||||
public class Demo : MonoBehaviour
|
||||
{
|
||||
private float secretSpeed = 1f;
|
||||
|
||||
[HotReload]
|
||||
void Tick()
|
||||
{
|
||||
transform.position += Vector3.right * secretSpeed * Time.deltaTime;
|
||||
}
|
||||
}";
|
||||
var result = AccessibilityValidator.ValidatePublicAccess(source, Bodies("Tick"), "Demo");
|
||||
|
||||
Assert.IsFalse(result.IsValid);
|
||||
Assert.IsTrue(result.Violations.Exists(v => v.MemberName == "secretSpeed"),
|
||||
"Should flag the private field 'secretSpeed'");
|
||||
StringAssert.DoesNotContain("transform", result.GetFormattedErrorMessage(),
|
||||
"Public/base members like 'transform' must not be flagged");
|
||||
}
|
||||
|
||||
// -------- end to end (full reload_file pipeline) --------
|
||||
|
||||
[Test]
|
||||
public void ReloadFileInPlace_EditedBody_AppliesViaWovenDispatch()
|
||||
{
|
||||
// Auto-discovery would do this at play start; register the target explicitly for the test.
|
||||
HotReloadRegistry.RegisterReloadableMethod(
|
||||
typeof(InPlaceReloadProbe).GetMethod(nameof(InPlaceReloadProbe.Compute)),
|
||||
new HotReloadWithOverridesAttribute());
|
||||
|
||||
// The "edited" source: Compute now writes hotValue instead of baseline.
|
||||
var editedSource = @"
|
||||
using Unity.Pipeline.HotReload;
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
public class InPlaceReloadProbe
|
||||
{
|
||||
public int baseline;
|
||||
public int hotValue;
|
||||
|
||||
[HotReload]
|
||||
public void Compute()
|
||||
{
|
||||
hotValue = 99;
|
||||
}
|
||||
}
|
||||
}";
|
||||
var path = Path.Combine(Application.temporaryCachePath, "InPlaceReloadProbe_edit.cs");
|
||||
File.WriteAllText(path, editedSource);
|
||||
|
||||
try
|
||||
{
|
||||
var response = HotReloadCommands.ReloadFile(path);
|
||||
Assert.IsTrue(response.Success, $"reload_file should succeed. Error: {response.ErrorDetails}");
|
||||
|
||||
var probe = new InPlaceReloadProbe();
|
||||
probe.Compute();
|
||||
|
||||
Assert.AreEqual(99, probe.hotValue, "Edited [HotReload] body should run via the woven dispatch");
|
||||
Assert.AreEqual(0, probe.baseline, "Original body should be skipped when the override runs");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReloadFileInPlace_WithPdb_StillAppliesViaWovenDispatch()
|
||||
{
|
||||
// Functional parity: the --pdb path (#line directives + portable PDB, unoptimized) must
|
||||
// still compile and dispatch exactly like the default path.
|
||||
HotReloadRegistry.RegisterReloadableMethod(
|
||||
typeof(InPlaceReloadProbe).GetMethod(nameof(InPlaceReloadProbe.Compute)),
|
||||
new HotReloadWithOverridesAttribute());
|
||||
|
||||
var editedSource = @"
|
||||
using Unity.Pipeline.HotReload;
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
public class InPlaceReloadProbe
|
||||
{
|
||||
public int baseline;
|
||||
public int hotValue;
|
||||
|
||||
[HotReload]
|
||||
public void Compute()
|
||||
{
|
||||
hotValue = 99;
|
||||
}
|
||||
}
|
||||
}";
|
||||
var path = Path.Combine(Application.temporaryCachePath, "InPlaceReloadProbe_pdb_edit.cs");
|
||||
File.WriteAllText(path, editedSource);
|
||||
|
||||
try
|
||||
{
|
||||
var response = HotReloadCommands.ReloadFile(path, pdb: true);
|
||||
Assert.IsTrue(response.Success, $"reload_file --pdb should succeed. Error: {response.ErrorDetails}");
|
||||
|
||||
var probe = new InPlaceReloadProbe();
|
||||
probe.Compute();
|
||||
|
||||
Assert.AreEqual(99, probe.hotValue, "Edited body should run via woven dispatch even with --pdb");
|
||||
Assert.AreEqual(0, probe.baseline, "Original body should be skipped when the override runs");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e5830856fb74475c9b4284d67bb9c991
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
using System.Collections;
|
||||
using System.IO;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.HotReload;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Integration tests for hot reload end-to-end functionality.
|
||||
/// Tests the complete workflow: register methods → create overrides → compile → verify runtime behavior.
|
||||
/// </summary>
|
||||
public class HotReloadIntegrationTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Test component that properly integrates hot reload pattern.
|
||||
/// </summary>
|
||||
public class IntegratedTestComponent : MonoBehaviour
|
||||
{
|
||||
public int lastValue;
|
||||
public string lastMessage;
|
||||
public Vector3 lastMovement;
|
||||
|
||||
[HotReloadWithOverrides]
|
||||
public void ProcessValue(int input)
|
||||
{
|
||||
HotReloadHelper.ExecuteWithHotReload(this, "ProcessValue",
|
||||
() => OriginalProcessValue(input), input);
|
||||
}
|
||||
|
||||
[HotReloadWithOverrides(Id = "CustomMovement")]
|
||||
public void Move(Vector3 direction)
|
||||
{
|
||||
HotReloadHelper.ExecuteWithHotReloadCustomId(this, "CustomMovement",
|
||||
() => OriginalMove(direction), direction);
|
||||
}
|
||||
|
||||
private void OriginalProcessValue(int input)
|
||||
{
|
||||
lastValue = input * 2;
|
||||
lastMessage = $"Original: {input} * 2 = {lastValue}";
|
||||
}
|
||||
|
||||
private void OriginalMove(Vector3 direction)
|
||||
{
|
||||
lastMovement = direction * 1.5f;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock hot reload override methods for testing.
|
||||
/// This simulates what would be in a HotReload/TestFile.cs.
|
||||
/// </summary>
|
||||
public static class MockHotReloadOverrides
|
||||
{
|
||||
[HotReloadOverrideMethod("IntegratedTestComponent.ProcessValue")]
|
||||
public static void ProcessValue(IntegratedTestComponent instance, int input)
|
||||
{
|
||||
instance.lastValue = input * 3; // Different calculation
|
||||
instance.lastMessage = $"HotReload: {input} * 3 = {instance.lastValue}";
|
||||
}
|
||||
|
||||
[HotReloadOverrideMethod("CustomMovement")]
|
||||
public static void Move(IntegratedTestComponent instance, Vector3 direction)
|
||||
{
|
||||
instance.lastMovement = direction * 2.5f; // Different multiplier
|
||||
}
|
||||
}
|
||||
|
||||
private IntegratedTestComponent testComponent;
|
||||
private GameObject testObject;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
// Create test component
|
||||
testObject = new GameObject("IntegrationTestObject");
|
||||
testComponent = testObject.AddComponent<IntegratedTestComponent>();
|
||||
|
||||
// Clear hot reload state (including reloadable methods for test isolation)
|
||||
HotReloadRegistry.ClearAllForTesting();
|
||||
|
||||
// Register the test methods as hot reloadable
|
||||
var processMethod = typeof(IntegratedTestComponent).GetMethod("ProcessValue");
|
||||
var moveMethod = typeof(IntegratedTestComponent).GetMethod("Move");
|
||||
|
||||
HotReloadRegistry.RegisterReloadableMethod(processMethod, new HotReloadWithOverridesAttribute());
|
||||
HotReloadRegistry.RegisterReloadableMethod(moveMethod, new HotReloadWithOverridesAttribute { Id = "CustomMovement" });
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
if (testObject != null)
|
||||
{
|
||||
Object.DestroyImmediate(testObject);
|
||||
}
|
||||
|
||||
HotReloadRegistry.ClearAllForTesting();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IntegratedComponent_OriginalBehavior_WorksCorrectly()
|
||||
{
|
||||
// Act - call methods without hot reload overrides
|
||||
testComponent.ProcessValue(5);
|
||||
testComponent.Move(Vector3.right);
|
||||
|
||||
// Assert - should use original implementations
|
||||
Assert.That(testComponent.lastValue, Is.EqualTo(10)); // 5 * 2
|
||||
Assert.That(testComponent.lastMessage, Is.EqualTo("Original: 5 * 2 = 10"));
|
||||
Assert.That(testComponent.lastMovement, Is.EqualTo(Vector3.right * 1.5f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IntegratedComponent_WithHotReloadOverride_UsesOverrideLogic()
|
||||
{
|
||||
// Arrange - register hot reload overrides
|
||||
var processOverride = typeof(MockHotReloadOverrides).GetMethod("ProcessValue");
|
||||
var moveOverride = typeof(MockHotReloadOverrides).GetMethod("Move");
|
||||
|
||||
HotReloadRegistry.RegisterMethodOverride(processOverride,
|
||||
new HotReloadOverrideMethodAttribute("IntegratedTestComponent.ProcessValue"),
|
||||
typeof(MockHotReloadOverrides));
|
||||
|
||||
HotReloadRegistry.RegisterMethodOverride(moveOverride,
|
||||
new HotReloadOverrideMethodAttribute("CustomMovement"),
|
||||
typeof(MockHotReloadOverrides));
|
||||
|
||||
// Act - call methods with hot reload overrides active
|
||||
testComponent.ProcessValue(5);
|
||||
testComponent.Move(Vector3.right);
|
||||
|
||||
// Assert - should use hot reload implementations
|
||||
Assert.That(testComponent.lastValue, Is.EqualTo(15)); // 5 * 3 (hot reload version)
|
||||
Assert.That(testComponent.lastMessage, Is.EqualTo("HotReload: 5 * 3 = 15"));
|
||||
Assert.That(testComponent.lastMovement, Is.EqualTo(Vector3.right * 2.5f)); // hot reload version
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HotReloadHelper_IsHotReloadActive_ReturnsCorrectStatus()
|
||||
{
|
||||
// Arrange - no overrides initially
|
||||
Assert.That(HotReloadHelper.IsHotReloadActive<IntegratedTestComponent>("ProcessValue"), Is.False);
|
||||
Assert.That(HotReloadHelper.IsHotReloadActive("CustomMovement"), Is.False);
|
||||
|
||||
// Act - register override
|
||||
var processOverride = typeof(MockHotReloadOverrides).GetMethod("ProcessValue");
|
||||
HotReloadRegistry.RegisterMethodOverride(processOverride,
|
||||
new HotReloadOverrideMethodAttribute("IntegratedTestComponent.ProcessValue"),
|
||||
typeof(MockHotReloadOverrides));
|
||||
|
||||
// Assert - now should be active
|
||||
Assert.That(HotReloadHelper.IsHotReloadActive<IntegratedTestComponent>("ProcessValue"), Is.True);
|
||||
Assert.That(HotReloadHelper.IsHotReloadActive("CustomMovement"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HotReloadRegistry_Stats_ReflectCurrentState()
|
||||
{
|
||||
// Arrange - register override
|
||||
var processOverride = typeof(MockHotReloadOverrides).GetMethod("ProcessValue");
|
||||
HotReloadRegistry.RegisterMethodOverride(processOverride,
|
||||
new HotReloadOverrideMethodAttribute("IntegratedTestComponent.ProcessValue"),
|
||||
typeof(MockHotReloadOverrides));
|
||||
|
||||
// Act
|
||||
var stats = HotReloadRegistry.GetStats();
|
||||
|
||||
// Assert
|
||||
Assert.That(stats.ReloadableMethodCount, Is.EqualTo(2)); // ProcessValue + Move
|
||||
Assert.That(stats.ActiveOverrideCount, Is.EqualTo(1)); // Only ProcessValue override
|
||||
Assert.That(stats.ActiveOverrideIds.Contains("IntegratedTestComponent.ProcessValue"), Is.True);
|
||||
Assert.That(stats.ReloadableMethodIds.Contains("IntegratedTestComponent.ProcessValue"), Is.True);
|
||||
Assert.That(stats.ReloadableMethodIds.Contains("CustomMovement"), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HotReloadRegistry_ClearOverrides_RestoresOriginalBehavior()
|
||||
{
|
||||
// Arrange - register and test override
|
||||
var processOverride = typeof(MockHotReloadOverrides).GetMethod("ProcessValue");
|
||||
HotReloadRegistry.RegisterMethodOverride(processOverride,
|
||||
new HotReloadOverrideMethodAttribute("IntegratedTestComponent.ProcessValue"),
|
||||
typeof(MockHotReloadOverrides));
|
||||
|
||||
testComponent.ProcessValue(5);
|
||||
Assert.That(testComponent.lastValue, Is.EqualTo(15)); // Hot reload behavior
|
||||
|
||||
// Act - clear overrides (but keep reloadable method registrations)
|
||||
HotReloadRegistry.ClearAllOverrides();
|
||||
|
||||
// Assert - should revert to original behavior
|
||||
testComponent.ProcessValue(5);
|
||||
Assert.That(testComponent.lastValue, Is.EqualTo(10)); // Original behavior
|
||||
Assert.That(testComponent.lastMessage, Is.EqualTo("Original: 5 * 2 = 10"));
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator HotReloadIntegration_MainThreadDispatch_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var processOverride = typeof(MockHotReloadOverrides).GetMethod("ProcessValue");
|
||||
HotReloadRegistry.RegisterMethodOverride(processOverride,
|
||||
new HotReloadOverrideMethodAttribute("IntegratedTestComponent.ProcessValue"),
|
||||
typeof(MockHotReloadOverrides));
|
||||
|
||||
// Act - invoke from main thread
|
||||
testComponent.ProcessValue(7);
|
||||
|
||||
// Yield to allow any async operations
|
||||
yield return null;
|
||||
|
||||
// Assert - hot reload should work on main thread
|
||||
Assert.That(testComponent.lastValue, Is.EqualTo(21)); // 7 * 3
|
||||
Assert.That(testComponent.lastMessage, Is.EqualTo("HotReload: 7 * 3 = 21"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bb9a7f677ab168b4bae8e1c0435d3e1b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
using System.Reflection;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.HotReload;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for HotReloadRegistry to verify method registration and statistics.
|
||||
/// Helps diagnose issues with hot reload method registration.
|
||||
/// </summary>
|
||||
public class HotReloadRegistryTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
HotReloadRegistry.ClearAllForTesting();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
HotReloadRegistry.ClearAllForTesting();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RegisterMethodOverride_DirectCall_UpdatesStats()
|
||||
{
|
||||
// Test direct registration to verify registry behavior
|
||||
|
||||
var method = typeof(TestRegistryClass).GetMethod("TestMethod");
|
||||
var attribute = method.GetCustomAttribute<HotReloadOverrideMethodAttribute>();
|
||||
var type = typeof(TestRegistryClass);
|
||||
|
||||
Assert.IsNotNull(method, "Test method should exist");
|
||||
Assert.IsNotNull(attribute, "Test attribute should exist");
|
||||
|
||||
// Get initial stats
|
||||
var initialStats = HotReloadRegistry.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 HotReloadWithOverridesAttribute { Id = "TargetClass1.TargetMethod1" };
|
||||
HotReloadRegistry.RegisterReloadableMethod(targetMethod, reloadableAttr);
|
||||
|
||||
// Register the type and method override
|
||||
HotReloadRegistry.RegisterHotReloadType(type, "TestAssembly");
|
||||
HotReloadRegistry.RegisterMethodOverride(method, attribute, type);
|
||||
|
||||
// Get final stats
|
||||
var finalStats = HotReloadRegistry.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 = HotReloadRegistry.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 HotReloadWithOverridesAttribute { Id = "TargetClass1.TargetMethod1" };
|
||||
HotReloadRegistry.RegisterReloadableMethod(targetMethod1, reloadableAttr1);
|
||||
|
||||
var targetMethod2 = typeof(MockTargetClass2).GetMethod("TargetMethod2");
|
||||
var reloadableAttr2 = new HotReloadWithOverridesAttribute { Id = "TargetClass2.TargetMethod2" };
|
||||
HotReloadRegistry.RegisterReloadableMethod(targetMethod2, reloadableAttr2);
|
||||
|
||||
// Register multiple methods
|
||||
var method1 = typeof(TestRegistryClass).GetMethod("TestMethod");
|
||||
var attribute1 = method1.GetCustomAttribute<HotReloadOverrideMethodAttribute>();
|
||||
HotReloadRegistry.RegisterHotReloadType(typeof(TestRegistryClass), "TestAssembly1");
|
||||
HotReloadRegistry.RegisterMethodOverride(method1, attribute1, typeof(TestRegistryClass));
|
||||
|
||||
var method2 = typeof(TestRegistryClass2).GetMethod("AnotherTestMethod");
|
||||
var attribute2 = method2.GetCustomAttribute<HotReloadOverrideMethodAttribute>();
|
||||
HotReloadRegistry.RegisterHotReloadType(typeof(TestRegistryClass2), "TestAssembly2");
|
||||
HotReloadRegistry.RegisterMethodOverride(method2, attribute2, typeof(TestRegistryClass2));
|
||||
|
||||
var finalStats = HotReloadRegistry.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 HotReloadWithOverridesAttribute { Id = "TargetClass1.TargetMethod1" };
|
||||
HotReloadRegistry.RegisterReloadableMethod(targetMethod, reloadableAttr);
|
||||
|
||||
// Register something first
|
||||
var method = typeof(TestRegistryClass).GetMethod("TestMethod");
|
||||
var attribute = method.GetCustomAttribute<HotReloadOverrideMethodAttribute>();
|
||||
HotReloadRegistry.RegisterHotReloadType(typeof(TestRegistryClass), "TestAssembly");
|
||||
HotReloadRegistry.RegisterMethodOverride(method, attribute, typeof(TestRegistryClass));
|
||||
|
||||
var statsBeforeClear = HotReloadRegistry.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
|
||||
HotReloadRegistry.ClearAllForTesting();
|
||||
|
||||
var statsAfterClear = HotReloadRegistry.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 HotReloadMethod attribute for registry testing.
|
||||
/// </summary>
|
||||
public static class TestRegistryClass
|
||||
{
|
||||
[HotReloadOverrideMethod("TargetClass1.TargetMethod1")]
|
||||
public static void TestMethod(MockTargetClass1 instance)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Second test class for multi-registration testing.
|
||||
/// </summary>
|
||||
public static class TestRegistryClass2
|
||||
{
|
||||
[HotReloadOverrideMethod("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;
|
||||
|
||||
[HotReloadWithOverrides(Id = "TargetClass1.TargetMethod1")]
|
||||
public void TargetMethod1()
|
||||
{
|
||||
value = 123;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock target class 2 for testing reloadable method registration.
|
||||
/// </summary>
|
||||
public class MockTargetClass2
|
||||
{
|
||||
public string text = "original";
|
||||
|
||||
[HotReloadWithOverrides(Id = "TargetClass2.TargetMethod2")]
|
||||
public void TargetMethod2()
|
||||
{
|
||||
text = "modified";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 522caacf8c1467a44b3f4f93157f373a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.HotReload;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Simple verification tests for hot reload infrastructure.
|
||||
/// These tests verify the core functionality works correctly in isolation.
|
||||
/// </summary>
|
||||
public class HotReloadVerificationTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
// Ensure clean state for each test
|
||||
HotReloadRegistry.ClearAllForTesting();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
// Clean up after each test
|
||||
HotReloadRegistry.ClearAllForTesting();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HotReloadRegistry_EmptyState_HasZeroStats()
|
||||
{
|
||||
// Act
|
||||
var stats = HotReloadRegistry.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 HotReloadRegistry_RegisterSingleMethod_CorrectCount()
|
||||
{
|
||||
// Arrange
|
||||
var testType = typeof(SimpleTestClass);
|
||||
var testMethod = testType.GetMethod("TestMethod");
|
||||
var attribute = new HotReloadWithOverridesAttribute();
|
||||
|
||||
// Act
|
||||
HotReloadRegistry.RegisterReloadableMethod(testMethod, attribute);
|
||||
|
||||
// Assert
|
||||
var stats = HotReloadRegistry.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 HotReloadRegistry_RegisterMultipleMethods_CorrectCount()
|
||||
{
|
||||
// Arrange
|
||||
var testType = typeof(SimpleTestClass);
|
||||
var method1 = testType.GetMethod("TestMethod");
|
||||
var method2 = testType.GetMethod("AnotherTestMethod");
|
||||
|
||||
// Act
|
||||
HotReloadRegistry.RegisterReloadableMethod(method1, new HotReloadWithOverridesAttribute());
|
||||
HotReloadRegistry.RegisterReloadableMethod(method2, new HotReloadWithOverridesAttribute { Id = "CustomId" });
|
||||
|
||||
// Assert
|
||||
var stats = HotReloadRegistry.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 HotReloadRegistry_TryInvokeWithoutOverride_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var testType = typeof(SimpleTestClass);
|
||||
var testMethod = testType.GetMethod("TestMethod");
|
||||
HotReloadRegistry.RegisterReloadableMethod(testMethod, new HotReloadWithOverridesAttribute());
|
||||
|
||||
var testInstance = new SimpleTestClass();
|
||||
|
||||
// Act
|
||||
var result = HotReloadRegistry.TryInvokeHotReload("SimpleTestClass.TestMethod", testInstance);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HotReloadHelper_GenerateMethodId_CorrectFormat()
|
||||
{
|
||||
// This tests the private method indirectly through the IsHotReloadActive method
|
||||
// Act & Assert
|
||||
Assert.That(HotReloadHelper.IsHotReloadActive<SimpleTestClass>("TestMethod"), Is.False);
|
||||
// The method ID generation is tested implicitly - if it's wrong, the registry lookup would fail
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HotReloadRegistry_ClearForTesting_RemovesAllState()
|
||||
{
|
||||
// Arrange - add some state
|
||||
var testType = typeof(SimpleTestClass);
|
||||
var testMethod = testType.GetMethod("TestMethod");
|
||||
HotReloadRegistry.RegisterReloadableMethod(testMethod, new HotReloadWithOverridesAttribute());
|
||||
|
||||
// Verify state exists
|
||||
var statsBefore = HotReloadRegistry.GetStats();
|
||||
Assert.That(statsBefore.ReloadableMethodCount, Is.EqualTo(1));
|
||||
|
||||
// Act
|
||||
HotReloadRegistry.ClearAllForTesting();
|
||||
|
||||
// Assert
|
||||
var statsAfter = HotReloadRegistry.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 hot reload testing.
|
||||
/// </summary>
|
||||
public class SimpleTestClass
|
||||
{
|
||||
public int value;
|
||||
|
||||
public void TestMethod()
|
||||
{
|
||||
value = 42;
|
||||
}
|
||||
|
||||
public int AnotherTestMethod(int input)
|
||||
{
|
||||
return input * 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d32ede884c994bb4b9f1b1bf6053697a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+425
@@ -0,0 +1,425 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline;
|
||||
using Unity.Pipeline.Models;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Editor;
|
||||
using Unity.Pipeline.Editor.Commands;
|
||||
using Unity.Pipeline.Security;
|
||||
using Unity.Pipeline.Tests.Runtime;
|
||||
using Unity.Pipeline.Threading;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for CLI instance discovery logic.
|
||||
/// These test the file-based discovery mechanism that CLI tools will use to find running Editor instances.
|
||||
/// </summary>
|
||||
public class InstanceDiscoveryTests
|
||||
{
|
||||
// Non-null only when this suite started the server itself (no live server was running).
|
||||
private EditorPipelineServer m_OwnedServer;
|
||||
// Port of the live (preferred) or owned server whose descriptor we test against.
|
||||
private int m_ServerPort;
|
||||
// Auth token of that server (from the descriptor for a live server, or the session token).
|
||||
private string m_ServerToken;
|
||||
private string m_TestProjectPath;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
// Setup command discovery
|
||||
CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
|
||||
|
||||
// Use current project path for testing
|
||||
m_TestProjectPath = Path.GetDirectoryName(Application.dataPath);
|
||||
|
||||
// Discovery is a read-only mechanism over the shared .unity-pipeline-port descriptor.
|
||||
// PREFER the already-running live server and NEVER disturb it: if a live server is
|
||||
// advertising a descriptor, test against that (read-only). Only when none is running do
|
||||
// we start our own descriptor-writing server, remembering we own it so TearDown stops
|
||||
// ONLY what we started — it must never stop or delete the live server's descriptor.
|
||||
var existing = InstanceDescriptor.ReadFromProjectRoot(m_TestProjectPath);
|
||||
if (existing != null && existing.Port > 0 && MockCliDiscovery.IsInstanceRunning(existing))
|
||||
{
|
||||
m_ServerPort = existing.Port;
|
||||
m_ServerToken = existing.EvalToken;
|
||||
}
|
||||
else
|
||||
{
|
||||
m_OwnedServer = new EditorPipelineServer();
|
||||
m_OwnedServer.Start();
|
||||
m_ServerPort = m_OwnedServer.Port;
|
||||
m_ServerToken = SecurityTokenManager.GetOrCreateToken();
|
||||
}
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
// Never stop the live server — only a server this suite started itself.
|
||||
m_OwnedServer?.Stop();
|
||||
m_OwnedServer = null;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void InstanceDiscovery_ReadDescriptorFile_ParsesCorrectly()
|
||||
{
|
||||
// Arrange - Server should have created descriptor file during startup
|
||||
var descriptorPath = InstanceDescriptor.GetDescriptorFilePath(m_TestProjectPath);
|
||||
|
||||
// Act - Read descriptor using CLI discovery logic
|
||||
var instance = MockCliDiscovery.ReadInstanceDescriptor(descriptorPath);
|
||||
|
||||
// Assert - Should successfully parse instance information
|
||||
Assert.IsNotNull(instance, "Should be able to read instance descriptor");
|
||||
Assert.AreEqual(m_ServerPort, instance.Port, "Port should match running server");
|
||||
Assert.Greater(instance.Pid, 0, "Should have valid process ID");
|
||||
Assert.AreEqual(m_TestProjectPath, instance.ProjectPath, "Project path should match");
|
||||
Assert.IsNotEmpty(instance.ProjectName, "Project name should not be empty");
|
||||
Assert.IsNotEmpty(instance.UnityVersion, "Unity version should not be empty");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void InstanceDiscovery_ScanDirectory_FindsRunningInstances()
|
||||
{
|
||||
// Arrange - We have a running server in the test project
|
||||
var searchPaths = new[] { m_TestProjectPath };
|
||||
|
||||
// Act - Discover instances using CLI discovery logic
|
||||
var instances = MockCliDiscovery.DiscoverInstances(searchPaths);
|
||||
|
||||
// Assert - Should find our test instance
|
||||
Assert.Greater(instances.Count(), 0, "Should find at least one running instance");
|
||||
|
||||
var testInstance = instances.FirstOrDefault(i => i.Port == m_ServerPort);
|
||||
Assert.IsNotNull(testInstance, "Should find our test server instance");
|
||||
Assert.IsTrue(testInstance.IsRunning, "Instance should be marked as running");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Server_BasicStatusEndpoint_WorksWithoutEditorProvider()
|
||||
{
|
||||
// Arrange - Start server WITHOUT EditorStatusProviderImpl (uses fallback)
|
||||
var testServer = new TestEditorPipelineServer();
|
||||
// Don't set status provider - should use default implementation
|
||||
testServer.Start();
|
||||
|
||||
try
|
||||
{
|
||||
// Give server time to fully start
|
||||
await Task.Delay(200);
|
||||
|
||||
// Act - Test status endpoint with default provider
|
||||
using (var client = new PipelineClient(testServer))
|
||||
{
|
||||
var response = await client.GetAsync("/api/status");
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(response.IsSuccess,
|
||||
$"Basic status endpoint should work. Status: {response.StatusCode}, Content: {response.RawResponse}");
|
||||
|
||||
// Verify it's basic server status (no Editor APIs)
|
||||
Assert.AreEqual("ready", response.JsonResponse["status"]?.ToString(), "Basic status should return 'ready'");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
testServer?.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Server_BasicCommandsEndpoint_WorksWithoutEditorProvider()
|
||||
{
|
||||
// Arrange - Start server WITHOUT EditorStatusProviderImpl
|
||||
var testServer = new TestEditorPipelineServer();
|
||||
testServer.Start();
|
||||
|
||||
try
|
||||
{
|
||||
// Act - Test commands endpoint
|
||||
using (var client = new PipelineClient(testServer))
|
||||
{
|
||||
var response = await client.GetAsync("/api/commands");
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(response.IsSuccess,
|
||||
$"Basic commands endpoint should work. Status: {response.StatusCode}, Content: {response.RawResponse}");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
testServer?.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Server_PortAssignment_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var testServer = new TestEditorPipelineServer();
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
testServer.Start();
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(testServer.IsRunning, "Server should be running");
|
||||
Assert.Greater(testServer.Port, 0, "Server should have a valid port");
|
||||
Assert.GreaterOrEqual(testServer.Port, 7800, "Port should be in valid range");
|
||||
Assert.LessOrEqual(testServer.Port, 7899, "Port should be in valid range");
|
||||
}
|
||||
finally
|
||||
{
|
||||
testServer?.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Dispatcher_BasicOperation_WorksCorrectly()
|
||||
{
|
||||
// A freshly initialized dispatcher runs Invoke synchronously on the calling (main) thread.
|
||||
var dispatcher = new Dispatcher();
|
||||
dispatcher.Initialize();
|
||||
try
|
||||
{
|
||||
var result = dispatcher.Invoke(() => "test_result", 1000);
|
||||
Assert.AreEqual("test_result", result, "Dispatcher should execute and return result");
|
||||
}
|
||||
finally
|
||||
{
|
||||
dispatcher.Shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EditorStatusCommand_DirectCall_WorksOnMainThread()
|
||||
{
|
||||
// This test ensures the editor_status command works correctly when called directly from main thread
|
||||
// Act - Direct call to the editor_status command (should work since Unity tests run on main thread)
|
||||
var status = Unity.Pipeline.Editor.Commands.EditorStatusCommand.GetEditorStatus();
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(status, "Status should not be null");
|
||||
Assert.IsNotNull(status.Status, "Status.Status should not be null");
|
||||
Assert.IsNotNull(status.UnityVersion, "Status.UnityVersion should not be null");
|
||||
Assert.IsNotNull(status.ProjectPath, "Status.ProjectPath should not be null");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EditorStatusCommand_Discovery_VerifyAvailable()
|
||||
{
|
||||
// Arrange & Act - Check if editor_status command is discoverable
|
||||
var commands = CommandRegistry.DiscoverCommands().ToList();
|
||||
|
||||
// Assert - editor_status command should be found
|
||||
var editorStatusCommand = commands.FirstOrDefault(c => c.Name == "editor_status");
|
||||
Assert.IsNotNull(editorStatusCommand, "editor_status command should be discoverable");
|
||||
|
||||
// Also verify we can invoke it via reflection (same path as server execution)
|
||||
var result = editorStatusCommand.Method.Invoke(null, new object[0]);
|
||||
Assert.IsNotNull(result, "Command execution via reflection should return a result");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task EditorStatusEndpoint_GetDetailedEditorInfo_RespondsCorrectly()
|
||||
{
|
||||
// Arrange - First verify the command is available
|
||||
var commands = CommandRegistry.DiscoverCommands().ToList();
|
||||
var editorStatusCommand = commands.FirstOrDefault(c => c.Name == "editor_status");
|
||||
|
||||
if (editorStatusCommand == null)
|
||||
{
|
||||
Assert.Fail($"editor_status command not found. Available commands: [{string.Join(", ", commands.Select(c => c.Name))}]");
|
||||
}
|
||||
|
||||
// Give dispatcher time to initialize
|
||||
await Task.Delay(200);
|
||||
|
||||
// Act - Make HTTP request to /api/editor_status (executes command via main thread marshaling)
|
||||
using (var client = new PipelineClient($"http://localhost:{m_ServerPort}", m_ServerToken))
|
||||
{
|
||||
var response = await client.GetAsync("/api/editor_status");
|
||||
|
||||
Assert.IsTrue(response.IsSuccess,
|
||||
$"Editor status endpoint should work. Status: {response.StatusCode}, Content: {response.RawResponse}");
|
||||
|
||||
// Verify it has Editor-specific data (from editor_status command)
|
||||
var statusJson = response.JsonResponse;
|
||||
Assert.IsNotNull(statusJson["status"], "Should include overall status");
|
||||
Assert.IsNotNull(statusJson["compiling"], "Should include compilation state");
|
||||
Assert.IsNotNull(statusJson["playMode"], "Should include play mode state");
|
||||
Assert.IsNotNull(statusJson["unityVersion"], "Should include Unity version");
|
||||
|
||||
// Verify it's rich Editor data, not basic server data
|
||||
Assert.Contains(statusJson["status"]?.ToString(), new[] { "ready", "compiling", "playing", "reloading" });
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task InstanceDiscovery_ValidateConnection_ConfirmsServerReachable()
|
||||
{
|
||||
// Arrange - Get our test instance
|
||||
var descriptorPath = InstanceDescriptor.GetDescriptorFilePath(m_TestProjectPath);
|
||||
var instance = MockCliDiscovery.ReadInstanceDescriptor(descriptorPath);
|
||||
Assert.IsNotNull(instance, "Should be able to read instance descriptor");
|
||||
|
||||
// Give server a moment to fully start if needed
|
||||
await Task.Delay(100);
|
||||
|
||||
// Act - Validate connection to the instance
|
||||
var isValid = await MockCliDiscovery.ValidateConnection(instance);
|
||||
|
||||
// Assert - Connection should be valid
|
||||
Assert.IsTrue(isValid, "Should be able to connect to running server");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void InstanceDiscovery_StaleDescriptor_DetectedAsNotRunning()
|
||||
{
|
||||
// Arrange - Create a fake descriptor with non-existent PID
|
||||
var staleDescriptor = new InstanceDescriptor
|
||||
{
|
||||
Pid = 99999, // Very unlikely to exist
|
||||
Port = 9999,
|
||||
ProjectPath = m_TestProjectPath,
|
||||
ProjectName = "TestProject",
|
||||
UnityVersion = Application.unityVersion,
|
||||
Mode = "editor",
|
||||
StartedAt = DateTime.UtcNow.AddHours(-1),
|
||||
LastHeartbeat = DateTime.UtcNow.AddMinutes(-10)
|
||||
};
|
||||
|
||||
// Act - Check if this represents a running instance
|
||||
var isRunning = MockCliDiscovery.IsInstanceRunning(staleDescriptor);
|
||||
|
||||
// Assert - Should be detected as not running
|
||||
Assert.IsFalse(isRunning, "Stale descriptor should be detected as not running");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock implementation of CLI discovery logic.
|
||||
/// Simulates what the real unity-cli will do for instance discovery and connection validation.
|
||||
/// </summary>
|
||||
public static class MockCliDiscovery
|
||||
{
|
||||
/// <summary>
|
||||
/// Read instance descriptor from a .unity-pipeline-port file.
|
||||
/// Simulates CLI reading discovery files.
|
||||
/// </summary>
|
||||
public static InstanceDescriptor ReadInstanceDescriptor(string filePath)
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(filePath);
|
||||
return JsonConvert.DeserializeObject<InstanceDescriptor>(json);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discover all running Pipeline instances in specified directories.
|
||||
/// Simulates CLI scanning for .unity-pipeline-port files.
|
||||
/// </summary>
|
||||
public static IEnumerable<DiscoveredInstance> DiscoverInstances(IEnumerable<string> searchPaths)
|
||||
{
|
||||
var instances = new List<DiscoveredInstance>();
|
||||
|
||||
foreach (var path in searchPaths)
|
||||
{
|
||||
if (!Directory.Exists(path))
|
||||
continue;
|
||||
|
||||
var descriptorPath = InstanceDescriptor.GetDescriptorFilePath(path);
|
||||
var descriptor = ReadInstanceDescriptor(descriptorPath);
|
||||
|
||||
if (descriptor != null)
|
||||
{
|
||||
instances.Add(new DiscoveredInstance
|
||||
{
|
||||
Descriptor = descriptor,
|
||||
IsRunning = IsInstanceRunning(descriptor),
|
||||
DescriptorPath = descriptorPath
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return instances;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if an instance is actually running by validating the PID.
|
||||
/// Simulates CLI process validation logic.
|
||||
/// </summary>
|
||||
public static bool IsInstanceRunning(InstanceDescriptor descriptor)
|
||||
{
|
||||
if (descriptor?.Pid == null || descriptor.Pid <= 0)
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var process = System.Diagnostics.Process.GetProcessById(descriptor.Pid);
|
||||
return !process.HasExited;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate that an instance is reachable via HTTP.
|
||||
/// Simulates CLI connection validation using basic /api/status endpoint.
|
||||
/// </summary>
|
||||
public static async Task<bool> ValidateConnection(InstanceDescriptor instance)
|
||||
{
|
||||
if (instance?.Port == null || instance.Port <= 0)
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
using (var client = new PipelineClient($"http://localhost:{instance.Port}", instance.EvalToken))
|
||||
{
|
||||
var response = await client.GetAsync("/api/status");
|
||||
return response.IsSuccess && response.JsonResponse?["status"]?.ToString() == "ready";
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a discovered Pipeline instance from CLI perspective.
|
||||
/// Contains both the descriptor data and validation status.
|
||||
/// </summary>
|
||||
public class DiscoveredInstance
|
||||
{
|
||||
public InstanceDescriptor Descriptor { get; set; }
|
||||
public bool IsRunning { get; set; }
|
||||
public string DescriptorPath { get; set; }
|
||||
|
||||
// Convenience properties
|
||||
public int Port => Descriptor?.Port ?? 0;
|
||||
public string ProjectPath => Descriptor?.ProjectPath ?? string.Empty;
|
||||
public string ProjectName => Descriptor?.ProjectName ?? string.Empty;
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e3d589d8fc3c2d6428c7ebabab8993cf
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using NUnit.Framework;
|
||||
using NUnit.Framework.Interfaces;
|
||||
using Unity.Pipeline.Models;
|
||||
using Unity.Pipeline.Tests.Runtime;
|
||||
using UnityEngine;
|
||||
|
||||
// Apply the guard to every test in this (editor test) assembly.
|
||||
[assembly: Unity.Pipeline.Tests.Editor.LiveServerGuard]
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Assembly-wide guard that runs after EVERY test in this assembly. If a live editor pipeline
|
||||
/// server was advertising its descriptor (.unity-pipeline-port) before the test, this asserts
|
||||
/// afterwards that the descriptor is still intact (present, same port) and the server is still
|
||||
/// listening — failing, and naming, any test that deletes/clobbers the descriptor or kills the
|
||||
/// listener. That turns "the whole dogfood run mysteriously dies" into a single named failure.
|
||||
///
|
||||
/// Temporary safety net until no test disturbs the live server. Known limitation: it cannot
|
||||
/// detect a server whose command dispatcher is dead but whose listener still answers /api/status,
|
||||
/// because exec-ing a real (main-thread) command from a teardown would deadlock.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Assembly)]
|
||||
public sealed class LiveServerGuardAttribute : Attribute, ITestAction
|
||||
{
|
||||
private InstanceDescriptor m_Before;
|
||||
|
||||
public ActionTargets Targets => ActionTargets.Test;
|
||||
|
||||
public void BeforeTest(ITest test)
|
||||
{
|
||||
m_Before = InstanceDescriptor.ReadFromProjectRoot(ProjectRoot());
|
||||
}
|
||||
|
||||
public void AfterTest(ITest test)
|
||||
{
|
||||
// Only protect a live server that already existed before this test ran. If none was
|
||||
// running (e.g. CI, or a test that legitimately starts/stops its own), there's nothing
|
||||
// to protect.
|
||||
if (m_Before == null || m_Before.Port <= 0)
|
||||
return;
|
||||
|
||||
var after = InstanceDescriptor.ReadFromProjectRoot(ProjectRoot());
|
||||
|
||||
// [Explicit] suites (ServerLifecycle, RuntimePipelineServerTests, …) are excluded from the
|
||||
// dogfood loop and knowingly disturb the live server — but they MUST hand it back intact in
|
||||
// their TearDown (GlobalServerStateGuard.Restore / PipelineServerStartup.RestartServer).
|
||||
// We don't require the descriptor to be byte-identical (a restart may pick a fresh port),
|
||||
// but the restore has to leave a live, reachable server. Asserting health here turns a
|
||||
// broken self-restore — which otherwise wedges the whole session/dogfood loop with no clue
|
||||
// who did it — into a single named failure. (AfterTest runs after [TearDown].)
|
||||
if (IsExplicit(test))
|
||||
{
|
||||
Assert.IsNotNull(after,
|
||||
$"'{test.Name}' ([Explicit]) left no live pipeline server descriptor after TearDown — its self-restore failed.");
|
||||
Assert.IsTrue(IsListening(after.Port, after.EvalToken),
|
||||
$"'{test.Name}' ([Explicit]) left the live pipeline server (port {after.Port}) not responding on /api/status after TearDown — its self-restore failed.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular dogfood tests must not disturb the live server at all.
|
||||
Assert.IsNotNull(after,
|
||||
$"'{test.Name}' deleted the live pipeline server descriptor (.unity-pipeline-port) — it disturbed the global server.");
|
||||
Assert.AreEqual(m_Before.Port, after.Port,
|
||||
$"'{test.Name}' changed the live pipeline server descriptor port (was {m_Before.Port}, now {after.Port}) — it clobbered the global server.");
|
||||
Assert.IsTrue(IsListening(after.Port, after.EvalToken),
|
||||
$"'{test.Name}' left the live pipeline server (port {after.Port}) not responding on /api/status — it disturbed the global server.");
|
||||
}
|
||||
|
||||
// True if the test or its declaring fixture is [Explicit] (checked at type level too, since
|
||||
// Unity doesn't reliably propagate class-level [Explicit] to leaf RunState).
|
||||
private static bool IsExplicit(ITest test)
|
||||
{
|
||||
if (test.RunState == RunState.Explicit)
|
||||
return true;
|
||||
var type = test.TypeInfo?.Type;
|
||||
if (type != null && type.GetCustomAttributes(true).Any(a => a.GetType().Name == "ExplicitAttribute"))
|
||||
return true;
|
||||
var method = test.Method?.MethodInfo;
|
||||
if (method != null && method.GetCustomAttributes(true).Any(a => a.GetType().Name == "ExplicitAttribute"))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsListening(int port, string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Run on the threadpool so this (main-thread) teardown can block on it without
|
||||
// deadlocking: /api/status is served on the listener thread and needs no dispatcher.
|
||||
// Every route now requires the bearer token, so authenticate with the descriptor's token.
|
||||
var probe = Task.Run(async () =>
|
||||
{
|
||||
using (var client = new PipelineClient($"http://localhost:{port}", token))
|
||||
return await client.GetStatusAsync();
|
||||
});
|
||||
return probe.Wait(3000) && probe.Result.IsSuccess;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ProjectRoot() => Path.GetDirectoryName(Application.dataPath);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a6c411c18ec52f144a0c6c0dedadf519
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
using System.Net.Http;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading.Tasks;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Security;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies the server is reachable over the explicit IPv4 loopback (127.0.0.1), independent of
|
||||
/// how "localhost" happens to resolve. This guards the intermittent connectivity failures caused
|
||||
/// by "localhost" resolving to IPv6 (::1) on some hosts while the server bound only IPv4 (or vice
|
||||
/// versa). Note: Unity's Mono HttpListener cannot serve requests that arrive over the IPv6
|
||||
/// loopback (it mis-parses the "[::1]" host and returns 400), so IPv4 is the reliable channel.
|
||||
/// </summary>
|
||||
public class LoopbackBindingTests
|
||||
{
|
||||
private static int GetStatus(string url)
|
||||
{
|
||||
var task = Task.Run(async () =>
|
||||
{
|
||||
using (var client = new HttpClient())
|
||||
using (var request = new HttpRequestMessage(HttpMethod.Get, url))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(
|
||||
"Authorization", $"Bearer {SecurityTokenManager.GetOrCreateToken()}");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
return (int)response.StatusCode;
|
||||
}
|
||||
});
|
||||
|
||||
return task.GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Status_OverIPv4Loopback_Returns200()
|
||||
{
|
||||
if (!Socket.OSSupportsIPv4)
|
||||
Assert.Ignore("IPv4 not available on this host.");
|
||||
|
||||
using (var server = new PipelineTestServer())
|
||||
Assert.AreEqual(200, GetStatus($"http://127.0.0.1:{server.Port}/api/status"),
|
||||
"Server should be reachable over the explicit IPv4 loopback.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8c462f11c3e0f2a41ad5827353bffb61
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 37da5c56100b4bf881a4907d63f35e3e
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+374
@@ -0,0 +1,374 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Editor.Commands.Materials;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor.Materials
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the CLI-213 material authoring commands (get_material_properties /
|
||||
/// set_material_properties). All assets are generated in-test under a temp folder and torn down
|
||||
/// afterwards. URP-specific assertions (e.g. _BaseColor / _Metallic Range / _BaseMap) are guarded
|
||||
/// so the suite still runs (with Assert.Ignore) on a Built-in RP project that lacks URP/Lit.
|
||||
/// </summary>
|
||||
public class MaterialCommandsTests
|
||||
{
|
||||
private const string Root = "Assets/__CLI213MaterialTest";
|
||||
|
||||
private string m_KnownShader; // a shader guaranteed to resolve on this project
|
||||
private bool m_HasUrpLit;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
ProjectPaths.ResetAuthoringRoot();
|
||||
m_HasUrpLit = Shader.Find("Universal Render Pipeline/Lit") != null;
|
||||
m_KnownShader = PickKnownShader();
|
||||
EnsureFolder(Root);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
ProjectPaths.ResetAuthoringRoot();
|
||||
if (AssetDatabase.IsValidFolder(Root))
|
||||
{
|
||||
AssetDatabase.DeleteAsset(Root);
|
||||
AssetDatabase.Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
private static string PickKnownShader()
|
||||
{
|
||||
if (Shader.Find("Standard") != null)
|
||||
return "Standard";
|
||||
if (Shader.Find("Universal Render Pipeline/Lit") != null)
|
||||
return "Universal Render Pipeline/Lit";
|
||||
return "Sprites/Default";
|
||||
}
|
||||
|
||||
/// <summary>The Color property to exercise: URP/Lit uses _BaseColor; built-in Standard uses _Color.</summary>
|
||||
private string ColorProp => m_HasUrpLit ? "_BaseColor" : "_Color";
|
||||
|
||||
private static void EnsureFolder(string folder)
|
||||
{
|
||||
if (!AssetDatabase.IsValidFolder(folder))
|
||||
AssetDatabase.CreateFolder("Assets", Path.GetFileName(folder));
|
||||
}
|
||||
|
||||
/// <summary>Create a .mat asset with the given shader and return a path-based handle to it.</summary>
|
||||
private ObjectRef CreateMaterial(string name, string shaderName = null)
|
||||
{
|
||||
var shader = Shader.Find(shaderName ?? m_KnownShader);
|
||||
Assert.IsNotNull(shader, $"Test prerequisite: shader '{shaderName ?? m_KnownShader}' must resolve");
|
||||
var mat = new Material(shader);
|
||||
var path = $"{Root}/{name}.mat";
|
||||
AssetDatabase.CreateAsset(mat, path);
|
||||
AssetDatabase.SaveAssets();
|
||||
return new ObjectRef { Path = path };
|
||||
}
|
||||
|
||||
/// <summary>Create a tiny 2x2 texture asset on disk and return a handle to it.</summary>
|
||||
private ObjectRef CreateTexture(string name)
|
||||
{
|
||||
var tex = new Texture2D(2, 2);
|
||||
tex.SetPixels(new[] { Color.white, Color.white, Color.white, Color.white });
|
||||
tex.Apply();
|
||||
var bytes = tex.EncodeToPNG();
|
||||
Object.DestroyImmediate(tex);
|
||||
|
||||
var path = $"{Root}/{name}.png";
|
||||
File.WriteAllBytes(Path.Combine(ProjectPaths.ProjectRoot, path), bytes);
|
||||
AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate);
|
||||
return new ObjectRef { Path = path };
|
||||
}
|
||||
|
||||
private static MaterialPropertyValue Prop(MaterialPropertiesResult r, string name)
|
||||
=> r.Properties.FirstOrDefault(p => p.Name == name);
|
||||
|
||||
/// <summary>
|
||||
/// Extract the float components of a Color/Vector property value. The direct (in-process) path
|
||||
/// returns a <c>float[]</c>; tolerate any IEnumerable (e.g. a JArray) so the helper also works
|
||||
/// if the value was JSON round-tripped.
|
||||
/// </summary>
|
||||
private static float[] Components(object value)
|
||||
{
|
||||
Assert.IsNotNull(value, "property value should not be null");
|
||||
return ((System.Collections.IEnumerable)value)
|
||||
.Cast<object>()
|
||||
.Select(o => System.Convert.ToSingle(o is JToken t ? (object)t.ToObject<float>() : o))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
// ---- get_material_properties --------------------------------------------------------------
|
||||
|
||||
[Test]
|
||||
public void Get_ReturnsShaderRenderQueueKeywordsAndProperties()
|
||||
{
|
||||
var handle = CreateMaterial("Get");
|
||||
var result = MaterialCommands.GetMaterialProperties(handle);
|
||||
|
||||
Assert.AreEqual(Shader.Find(m_KnownShader).name, result.Shader);
|
||||
Assert.IsNotNull(result.EnabledKeywords);
|
||||
Assert.IsNotEmpty(result.Properties, "A real shader should expose at least one property");
|
||||
|
||||
var color = Prop(result, ColorProp);
|
||||
Assert.IsNotNull(color, $"{ColorProp} should be present");
|
||||
Assert.AreEqual("Color", color.Type);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Get_Urp_BaseColorIsColor_MetallicIsRangeWithLimits()
|
||||
{
|
||||
if (!m_HasUrpLit)
|
||||
Assert.Ignore("URP/Lit not available in this project; skipping URP-specific assertion (AC #1).");
|
||||
|
||||
var handle = CreateMaterial("UrpGet", "Universal Render Pipeline/Lit");
|
||||
var result = MaterialCommands.GetMaterialProperties(handle);
|
||||
|
||||
var baseColor = Prop(result, "_BaseColor");
|
||||
Assert.IsNotNull(baseColor, "_BaseColor should be present");
|
||||
Assert.AreEqual("Color", baseColor.Type);
|
||||
|
||||
var metallic = Prop(result, "_Metallic");
|
||||
Assert.IsNotNull(metallic, "_Metallic should be present");
|
||||
Assert.AreEqual("Range", metallic.Type);
|
||||
Assert.IsNotNull(metallic.Range, "_Metallic (Range) should carry range limits");
|
||||
Assert.LessOrEqual(metallic.Range.Min, metallic.Range.Max);
|
||||
}
|
||||
|
||||
// ---- set_material_properties: color -------------------------------------------------------
|
||||
|
||||
[Test]
|
||||
public void Set_BaseColor_ViaArray_AppliesRed()
|
||||
{
|
||||
var handle = CreateMaterial("SetColorArray");
|
||||
var props = new JObject { [ColorProp] = new JArray(1, 0, 0, 1) };
|
||||
|
||||
var result = MaterialCommands.SetMaterialProperties(handle, properties: props);
|
||||
|
||||
CollectionAssert.Contains(result.Applied, ColorProp);
|
||||
var read = MaterialCommands.GetMaterialProperties(handle);
|
||||
var c = Components(Prop(read, ColorProp).Value);
|
||||
Assert.AreEqual(1f, c[0], 1e-4f);
|
||||
Assert.AreEqual(0f, c[1], 1e-4f);
|
||||
Assert.AreEqual(0f, c[2], 1e-4f);
|
||||
Assert.AreEqual(1f, c[3], 1e-4f);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Set_BaseColor_ViaHex_AppliesGreen()
|
||||
{
|
||||
var handle = CreateMaterial("SetColorHex");
|
||||
var props = new JObject { [ColorProp] = "#00FF00" };
|
||||
|
||||
MaterialCommands.SetMaterialProperties(handle, properties: props);
|
||||
|
||||
var read = MaterialCommands.GetMaterialProperties(handle);
|
||||
var c = Components(Prop(read, ColorProp).Value);
|
||||
Assert.AreEqual(0f, c[0], 1e-3f);
|
||||
Assert.AreEqual(1f, c[1], 1e-3f);
|
||||
Assert.AreEqual(0f, c[2], 1e-3f);
|
||||
Assert.AreEqual(1f, c[3], 1e-3f, "hex without alpha should default alpha to 1.0");
|
||||
}
|
||||
|
||||
// ---- set_material_properties: texture -----------------------------------------------------
|
||||
|
||||
[Test]
|
||||
public void Set_Texture_AssignsThenClears()
|
||||
{
|
||||
if (!m_HasUrpLit)
|
||||
Assert.Ignore("URP/Lit not available; skipping _BaseMap texture test (AC #4).");
|
||||
|
||||
var handle = CreateMaterial("SetTex", "Universal Render Pipeline/Lit");
|
||||
var tex = CreateTexture("Albedo");
|
||||
|
||||
var set = MaterialCommands.SetMaterialProperties(handle,
|
||||
properties: new JObject { ["_BaseMap"] = JToken.FromObject(tex) });
|
||||
CollectionAssert.Contains(set.Applied, "_BaseMap");
|
||||
|
||||
var loadedTex = AssetDatabase.LoadAssetAtPath<Texture>(tex.Path);
|
||||
var mat = AssetDatabase.LoadAssetAtPath<Material>(handle.Path);
|
||||
Assert.AreEqual(loadedTex, mat.GetTexture("_BaseMap"), "texture should be assigned");
|
||||
|
||||
// Clear with null.
|
||||
MaterialCommands.SetMaterialProperties(handle,
|
||||
properties: new JObject { ["_BaseMap"] = JValue.CreateNull() });
|
||||
mat = AssetDatabase.LoadAssetAtPath<Material>(handle.Path);
|
||||
Assert.IsNull(mat.GetTexture("_BaseMap"), "passing null should clear the texture");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Set_Texture_OutsideAuthoringRoot_IsRejected()
|
||||
{
|
||||
if (!m_HasUrpLit)
|
||||
Assert.Ignore("URP/Lit not available; skipping _BaseMap sandbox test (AC #13).");
|
||||
|
||||
// Confine the authoring root to a subfolder, then reference a texture created OUTSIDE it.
|
||||
var outsideTex = CreateTexture("OutsideAlbedo"); // lives directly under Root
|
||||
var sub = $"{Root}/Sub";
|
||||
if (!AssetDatabase.IsValidFolder(sub))
|
||||
AssetDatabase.CreateFolder(Root, "Sub");
|
||||
var handle = CreateMaterial("Sub/SetTexSandbox", "Universal Render Pipeline/Lit");
|
||||
|
||||
ProjectPaths.AuthoringRoot = sub; // now Root/OutsideAlbedo.png is outside the root
|
||||
|
||||
var ex = Assert.Throws<System.ArgumentException>(() =>
|
||||
MaterialCommands.SetMaterialProperties(handle,
|
||||
properties: new JObject { ["_BaseMap"] = JToken.FromObject(outsideTex) }));
|
||||
StringAssert.Contains("outside the authoring root", ex.Message);
|
||||
}
|
||||
|
||||
// ---- shader reassignment ------------------------------------------------------------------
|
||||
|
||||
[Test]
|
||||
public void Set_Shader_Reassigns()
|
||||
{
|
||||
if (Shader.Find("Standard") == null)
|
||||
Assert.Ignore("Built-in Standard shader not available; skipping shader-reassign test (AC #5).");
|
||||
|
||||
var handle = CreateMaterial("Reassign", m_KnownShader);
|
||||
var result = MaterialCommands.SetMaterialProperties(handle, shader: "Standard");
|
||||
|
||||
Assert.AreEqual("Standard", result.Shader);
|
||||
var read = MaterialCommands.GetMaterialProperties(handle);
|
||||
Assert.AreEqual("Standard", read.Shader);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Set_Shader_NotFound_ThrowsAndWritesNothing()
|
||||
{
|
||||
var handle = CreateMaterial("BadShader");
|
||||
var before = MaterialCommands.GetMaterialProperties(handle).Shader;
|
||||
|
||||
var ex = Assert.Throws<ShaderNotFoundException>(() =>
|
||||
MaterialCommands.SetMaterialProperties(handle, shader: "No/Such/Shader__CLI213",
|
||||
properties: new JObject { [ColorProp] = new JArray(1, 0, 0, 1) }));
|
||||
StringAssert.Contains("shader_not_found", ex.Message);
|
||||
Assert.AreEqual("shader_not_found", ex.Code);
|
||||
|
||||
var after = MaterialCommands.GetMaterialProperties(handle).Shader;
|
||||
Assert.AreEqual(before, after, "a shader_not_found must write nothing");
|
||||
}
|
||||
|
||||
// ---- keywords -----------------------------------------------------------------------------
|
||||
|
||||
[Test]
|
||||
public void Set_Keyword_EnableThenDisable()
|
||||
{
|
||||
var handle = CreateMaterial("Keyword");
|
||||
const string keyword = "_EMISSION";
|
||||
|
||||
MaterialCommands.SetMaterialProperties(handle, enableKeywords: new[] { keyword });
|
||||
var enabled = MaterialCommands.GetMaterialProperties(handle).EnabledKeywords;
|
||||
CollectionAssert.Contains(enabled, keyword);
|
||||
|
||||
MaterialCommands.SetMaterialProperties(handle, disableKeywords: new[] { keyword });
|
||||
var disabled = MaterialCommands.GetMaterialProperties(handle).EnabledKeywords;
|
||||
CollectionAssert.DoesNotContain(disabled, keyword);
|
||||
}
|
||||
|
||||
// ---- render queue -------------------------------------------------------------------------
|
||||
|
||||
[Test]
|
||||
public void Set_RenderQueue_ExplicitThenInherit()
|
||||
{
|
||||
var handle = CreateMaterial("Queue");
|
||||
|
||||
MaterialCommands.SetMaterialProperties(handle, renderQueue: 3000);
|
||||
Assert.AreEqual(3000, MaterialCommands.GetMaterialProperties(handle).RenderQueue,
|
||||
"an explicit renderQueue must be reflected by the command's own read-back");
|
||||
|
||||
MaterialCommands.SetMaterialProperties(handle, renderQueue: -1);
|
||||
// Validate inheritance via the command's raw queue read rather than the effective queue: the shader
|
||||
// default can legitimately equal the prior override (e.g. 3000), so comparing the effective
|
||||
// value would be flaky. The command returns -1 exactly when the material inherits the shader's.
|
||||
Assert.AreEqual(-1, MaterialCommands.GetMaterialProperties(handle).RenderQueue,
|
||||
"renderQueue -1 should clear the override and inherit from the shader");
|
||||
}
|
||||
|
||||
// ---- unknown / mismatch -------------------------------------------------------------------
|
||||
|
||||
[Test]
|
||||
public void Set_UnknownProperty_GoesToUnknown_StillAppliesOthers()
|
||||
{
|
||||
var handle = CreateMaterial("Unknown");
|
||||
var props = new JObject
|
||||
{
|
||||
[ColorProp] = new JArray(0, 0, 1, 1),
|
||||
["_DefinitelyNotARealProp_CLI213"] = 5,
|
||||
};
|
||||
|
||||
var result = MaterialCommands.SetMaterialProperties(handle, properties: props);
|
||||
|
||||
CollectionAssert.Contains(result.Applied, ColorProp);
|
||||
Assert.IsTrue(result.Unknown.Any(u => u.Contains("_DefinitelyNotARealProp_CLI213")),
|
||||
"unknown property should be reported in unknown[]");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Set_TypeMismatch_GoesToUnknownWithReason()
|
||||
{
|
||||
if (!m_HasUrpLit)
|
||||
Assert.Ignore("URP/Lit not available; skipping _Metallic type-mismatch test.");
|
||||
|
||||
var handle = CreateMaterial("Mismatch", "Universal Render Pipeline/Lit");
|
||||
// _Metallic is a Range (number); send an array to force a mismatch.
|
||||
var props = new JObject
|
||||
{
|
||||
["_BaseColor"] = new JArray(1, 1, 1, 1),
|
||||
["_Metallic"] = new JArray(1, 2, 3),
|
||||
};
|
||||
|
||||
var result = MaterialCommands.SetMaterialProperties(handle, properties: props);
|
||||
|
||||
CollectionAssert.Contains(result.Applied, "_BaseColor");
|
||||
Assert.IsTrue(result.Unknown.Any(u => u.Contains("_Metallic")),
|
||||
"a type mismatch should be reported in unknown[] with a reason");
|
||||
}
|
||||
|
||||
// ---- dry_run ------------------------------------------------------------------------------
|
||||
|
||||
[Test]
|
||||
public void Set_DryRun_ReportsButDoesNotWrite()
|
||||
{
|
||||
var handle = CreateMaterial("DryRun");
|
||||
var beforeColor = Components(Prop(MaterialCommands.GetMaterialProperties(handle), ColorProp).Value);
|
||||
|
||||
var props = new JObject { [ColorProp] = new JArray(1, 0, 0, 1) };
|
||||
var result = MaterialCommands.SetMaterialProperties(handle, properties: props, dryRun: true);
|
||||
|
||||
CollectionAssert.Contains(result.Applied, ColorProp);
|
||||
|
||||
var afterColor = Components(Prop(MaterialCommands.GetMaterialProperties(handle), ColorProp).Value);
|
||||
CollectionAssert.AreEqual(beforeColor, afterColor, "dry_run must not change the material");
|
||||
}
|
||||
|
||||
// ---- ViaClient ----------------------------------------------------------------------------
|
||||
|
||||
[Test]
|
||||
public void Set_ViaClient_AppliesColor()
|
||||
{
|
||||
var handle = CreateMaterial("ViaClient");
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("set_material_properties", new
|
||||
{
|
||||
material = new { path = handle.Path },
|
||||
properties = new JObject { [ColorProp] = new JArray(1, 0, 0, 1) },
|
||||
});
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, $"set_material_properties should succeed: {response.Error}");
|
||||
|
||||
var read = MaterialCommands.GetMaterialProperties(handle);
|
||||
var c = Components(Prop(read, ColorProp).Value);
|
||||
Assert.AreEqual(1f, c[0], 1e-4f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a96b5f4084674e609bbfbb12e73bde08
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
using System.Linq;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Editor.Commands.Materials;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor.Materials
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the CLI-213 shader discovery / introspection commands (list_shaders /
|
||||
/// get_shader_properties). URP-specific assertions are guarded with Assert.Ignore so the suite runs
|
||||
/// on a Built-in RP project too.
|
||||
/// </summary>
|
||||
public class ShaderCommandsTests
|
||||
{
|
||||
private bool m_HasUrpLit;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
ProjectPaths.ResetAuthoringRoot();
|
||||
m_HasUrpLit = Shader.Find("Universal Render Pipeline/Lit") != null;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => ProjectPaths.ResetAuthoringRoot();
|
||||
|
||||
// ---- list_shaders -------------------------------------------------------------------------
|
||||
|
||||
[Test]
|
||||
public void ListShaders_FilterLit_ReturnsTheLitShader()
|
||||
{
|
||||
var results = ShaderCommands.ListShaders(filter: "Lit");
|
||||
|
||||
Assert.IsNotEmpty(results, "filtering by 'Lit' should return at least one shader");
|
||||
Assert.IsTrue(results.All(s => s.Name.IndexOf("Lit", System.StringComparison.OrdinalIgnoreCase) >= 0),
|
||||
"every result should contain the filter substring");
|
||||
|
||||
// Only assert the specific shader name when URP/Lit is present; this branch never runs
|
||||
// otherwise, so a plain constant keeps the intent clear.
|
||||
if (m_HasUrpLit)
|
||||
Assert.IsTrue(results.Any(s => s.Name == "Universal Render Pipeline/Lit"),
|
||||
"the active pipeline's Lit shader should be listed (AC #10)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ListShaders_ReportsIsSupportedAndName()
|
||||
{
|
||||
var results = ShaderCommands.ListShaders(filter: "Lit", limit: 50);
|
||||
if (results.Count == 0)
|
||||
Assert.Ignore("No 'Lit' shaders in this project.");
|
||||
|
||||
foreach (var s in results)
|
||||
Assert.IsFalse(string.IsNullOrEmpty(s.Name), "every shader entry must carry a name");
|
||||
// isSupported is a bool that always serializes; just assert the field is meaningful for a known shader.
|
||||
var known = results.FirstOrDefault();
|
||||
Assert.IsNotNull(known);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ListShaders_RespectsLimit()
|
||||
{
|
||||
var results = ShaderCommands.ListShaders(limit: 3);
|
||||
Assert.LessOrEqual(results.Count, 3, "limit should cap the result count");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ListShaders_ExcludeBuiltin_DropsEngineShaders()
|
||||
{
|
||||
var withBuiltin = ShaderCommands.ListShaders(includeBuiltin: true);
|
||||
var withoutBuiltin = ShaderCommands.ListShaders(includeBuiltin: false);
|
||||
|
||||
Assert.IsFalse(withoutBuiltin.Any(s => s.IsBuiltin),
|
||||
"includeBuiltin=false must not return any built-in shaders");
|
||||
Assert.GreaterOrEqual(withBuiltin.Count, withoutBuiltin.Count);
|
||||
}
|
||||
|
||||
// ---- get_shader_properties ----------------------------------------------------------------
|
||||
|
||||
[Test]
|
||||
public void GetShaderProperties_Urp_ReturnsBaseColorMetallicBaseMap()
|
||||
{
|
||||
if (!m_HasUrpLit)
|
||||
Assert.Ignore("URP/Lit not available; skipping URP property introspection (AC #11).");
|
||||
|
||||
var result = ShaderCommands.GetShaderProperties(shader: "Universal Render Pipeline/Lit");
|
||||
Assert.AreEqual("Universal Render Pipeline/Lit", result.Shader);
|
||||
|
||||
var baseColor = result.Properties.FirstOrDefault(p => p.Name == "_BaseColor");
|
||||
Assert.IsNotNull(baseColor, "_BaseColor should be present");
|
||||
Assert.AreEqual("Color", baseColor.Type);
|
||||
|
||||
var metallic = result.Properties.FirstOrDefault(p => p.Name == "_Metallic");
|
||||
Assert.IsNotNull(metallic, "_Metallic should be present");
|
||||
Assert.AreEqual("Range", metallic.Type);
|
||||
Assert.IsNotNull(metallic.Range, "_Metallic should carry min/max");
|
||||
|
||||
var baseMap = result.Properties.FirstOrDefault(p => p.Name == "_BaseMap");
|
||||
Assert.IsNotNull(baseMap, "_BaseMap should be present");
|
||||
Assert.AreEqual("TexEnv", baseMap.Type);
|
||||
Assert.AreEqual("Tex2D", baseMap.TextureDimension);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetShaderProperties_KnownShader_ReturnsProperties()
|
||||
{
|
||||
// Shader-agnostic: any real shader exposes a non-empty property list with names + types.
|
||||
var shaderName = m_HasUrpLit ? "Universal Render Pipeline/Lit"
|
||||
: (Shader.Find("Standard") != null ? "Standard" : "Sprites/Default");
|
||||
|
||||
var result = ShaderCommands.GetShaderProperties(shader: shaderName);
|
||||
Assert.AreEqual(shaderName, result.Shader);
|
||||
Assert.IsNotEmpty(result.Properties);
|
||||
foreach (var p in result.Properties)
|
||||
{
|
||||
Assert.IsFalse(string.IsNullOrEmpty(p.Name));
|
||||
Assert.IsFalse(string.IsNullOrEmpty(p.Type));
|
||||
Assert.IsNotNull(p.Flags, "flags should never be null (empty list when None)");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetShaderProperties_FromMaterial_ReadsShaderOffMaterial()
|
||||
{
|
||||
var shaderName = Shader.Find("Standard") != null ? "Standard"
|
||||
: (m_HasUrpLit ? "Universal Render Pipeline/Lit" : "Sprites/Default");
|
||||
|
||||
var folder = "Assets/__CLI213ShaderTest";
|
||||
if (!AssetDatabase.IsValidFolder(folder))
|
||||
AssetDatabase.CreateFolder("Assets", "__CLI213ShaderTest");
|
||||
try
|
||||
{
|
||||
var path = $"{folder}/Mat.mat";
|
||||
AssetDatabase.CreateAsset(new Material(Shader.Find(shaderName)), path);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
var result = ShaderCommands.GetShaderProperties(material: new ObjectRef { Path = path });
|
||||
Assert.AreEqual(shaderName, result.Shader);
|
||||
Assert.IsNotEmpty(result.Properties);
|
||||
}
|
||||
finally
|
||||
{
|
||||
AssetDatabase.DeleteAsset(folder);
|
||||
AssetDatabase.Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetShaderProperties_NotFound_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ShaderNotFoundException>(() =>
|
||||
ShaderCommands.GetShaderProperties(shader: "No/Such/Shader__CLI213"));
|
||||
StringAssert.Contains("shader_not_found", ex.Message);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetShaderProperties_NeitherArg_Throws()
|
||||
{
|
||||
Assert.Throws<System.ArgumentException>(() => ShaderCommands.GetShaderProperties());
|
||||
}
|
||||
|
||||
// ---- ViaClient ----------------------------------------------------------------------------
|
||||
|
||||
[Test]
|
||||
public void ListShaders_ViaClient_Succeeds()
|
||||
{
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("list_shaders", new { filter = "Lit", limit = 20 });
|
||||
Assert.IsTrue(response.IsSuccess, $"list_shaders should succeed: {response.Error}");
|
||||
Assert.IsTrue(response.HasValidJson, "response should be valid JSON");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cd771dc30e5f4fbe9611c004593a09c4
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user