Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3ffa6a16c12a1a248884139af2530848
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: de3774b0893944d8ac6871cbcaa631ab
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+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>
|
||||
class AnimationClipCommandsTests
|
||||
{
|
||||
private const string Root = "Assets/__CLI214AnimTest";
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
ProjectPaths.ResetAuthoringRoot();
|
||||
if (AssetDatabase.IsValidFolder(Root))
|
||||
{
|
||||
AssetDatabase.DeleteAsset(Root);
|
||||
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||
}
|
||||
}
|
||||
|
||||
private static ObjectRef PathRef(string path) => new ObjectRef { Path = path };
|
||||
|
||||
private static JArray TwoKeys() => new JArray
|
||||
{
|
||||
new JObject { ["time"] = 0f, ["value"] = 0f },
|
||||
new JObject { ["time"] = 1f, ["value"] = 5f }
|
||||
};
|
||||
|
||||
[Test]
|
||||
public void CreateAnimationClip_SetsFrameRateAndLoop()
|
||||
{
|
||||
var path = Root + "/Clips/Walk.anim";
|
||||
var result = AnimationClipCommands.CreateAnimationClip(path, frameRate: 30f, loop: true);
|
||||
|
||||
Assert.AreEqual(path, result.AssetPath);
|
||||
|
||||
var loaded = AssetDatabase.LoadAssetAtPath<AnimationClip>(path);
|
||||
Assert.IsNotNull(loaded, "Clip should exist on disk");
|
||||
Assert.AreEqual(30f, loaded.frameRate, 0.001f);
|
||||
|
||||
var info = AnimationClipCommands.GetAnimationClip(PathRef(path));
|
||||
Assert.AreEqual(30f, info.FrameRate, 0.001f);
|
||||
Assert.IsTrue(info.Loop, "loop should be reflected by get_animation_clip");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateAnimationClip_OverwriteWithoutConfirm_Throws()
|
||||
{
|
||||
var path = Root + "/Clips/Dup.anim";
|
||||
AnimationClipCommands.CreateAnimationClip(path);
|
||||
|
||||
Assert.Throws<System.ArgumentException>(() => AnimationClipCommands.CreateAnimationClip(path));
|
||||
Assert.DoesNotThrow(() => AnimationClipCommands.CreateAnimationClip(path, confirm: true));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateAnimationClip_OutOfProject_Throws()
|
||||
{
|
||||
Assert.Throws<System.ArgumentException>(() => AnimationClipCommands.CreateAnimationClip("../Outside.anim"));
|
||||
Assert.Throws<System.ArgumentException>(() => AnimationClipCommands.CreateAnimationClip("Assets/../Outside.anim"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateAnimationClip_DryRun_WritesNothing()
|
||||
{
|
||||
var path = Root + "/Clips/DryRun.anim";
|
||||
var result = AnimationClipCommands.CreateAnimationClip(path, dryRun: true);
|
||||
|
||||
Assert.AreEqual(path, result.AssetPath);
|
||||
Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(path), "dry_run must not write a clip");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetAnimationCurve_AddsCurveWithKeys()
|
||||
{
|
||||
var path = Root + "/Clips/Curve.anim";
|
||||
AnimationClipCommands.CreateAnimationClip(path);
|
||||
|
||||
var result = AnimationClipCommands.SetAnimationCurve(
|
||||
PathRef(path), path: "", type: "Transform", property: "m_LocalPosition.x", keys: TwoKeys());
|
||||
|
||||
Assert.AreEqual(2, result.KeyCount);
|
||||
Assert.AreEqual("m_LocalPosition.x", result.Binding.Property);
|
||||
|
||||
var info = AnimationClipCommands.GetAnimationClip(PathRef(path), includeKeys: true);
|
||||
Assert.AreEqual(1, info.Bindings.Count);
|
||||
var binding = info.Bindings[0];
|
||||
Assert.AreEqual("Transform", binding.Type);
|
||||
Assert.AreEqual("m_LocalPosition.x", binding.Property);
|
||||
Assert.AreEqual(2, binding.KeyCount);
|
||||
Assert.IsNotNull(binding.Keys);
|
||||
Assert.AreEqual(2, binding.Keys.Count);
|
||||
Assert.AreEqual(0f, binding.Keys[0].Value, 0.001f);
|
||||
Assert.AreEqual(5f, binding.Keys[1].Value, 0.001f);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetAnimationCurve_ReplaceExisting_Overwrites()
|
||||
{
|
||||
var path = Root + "/Clips/Replace.anim";
|
||||
AnimationClipCommands.CreateAnimationClip(path);
|
||||
|
||||
AnimationClipCommands.SetAnimationCurve(PathRef(path), "", "Transform", "m_LocalPosition.x", TwoKeys());
|
||||
|
||||
var threeKeys = new JArray
|
||||
{
|
||||
new JObject { ["time"] = 0f, ["value"] = 0f },
|
||||
new JObject { ["time"] = 0.5f, ["value"] = 2f },
|
||||
new JObject { ["time"] = 1f, ["value"] = 4f }
|
||||
};
|
||||
var result = AnimationClipCommands.SetAnimationCurve(PathRef(path), "", "Transform", "m_LocalPosition.x", threeKeys);
|
||||
Assert.AreEqual(3, result.KeyCount);
|
||||
|
||||
var info = AnimationClipCommands.GetAnimationClip(PathRef(path));
|
||||
// Same binding => not duplicated; the single binding now has 3 keys.
|
||||
Assert.AreEqual(1, info.Bindings.Count, "Replacing a binding must not duplicate it");
|
||||
Assert.AreEqual(3, info.Bindings[0].KeyCount);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetAnimationCurve_DryRun_WritesNothing()
|
||||
{
|
||||
var path = Root + "/Clips/DryCurve.anim";
|
||||
AnimationClipCommands.CreateAnimationClip(path);
|
||||
|
||||
var result = AnimationClipCommands.SetAnimationCurve(
|
||||
PathRef(path), "", "Transform", "m_LocalPosition.x", TwoKeys(), dryRun: true);
|
||||
Assert.AreEqual(2, result.KeyCount);
|
||||
|
||||
var info = AnimationClipCommands.GetAnimationClip(PathRef(path));
|
||||
Assert.AreEqual(0, info.Bindings.Count, "dry_run must not write a curve");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RemoveAnimationCurve_WithoutConfirm_Throws()
|
||||
{
|
||||
var path = Root + "/Clips/Remove.anim";
|
||||
AnimationClipCommands.CreateAnimationClip(path);
|
||||
AnimationClipCommands.SetAnimationCurve(PathRef(path), "", "Transform", "m_LocalPosition.x", TwoKeys());
|
||||
|
||||
Assert.Throws<System.ArgumentException>(
|
||||
() => AnimationClipCommands.RemoveAnimationCurve(PathRef(path), "", "Transform", "m_LocalPosition.x"),
|
||||
"remove without confirm should be rejected");
|
||||
|
||||
var info = AnimationClipCommands.GetAnimationClip(PathRef(path));
|
||||
Assert.AreEqual(1, info.Bindings.Count, "binding should survive a refused remove");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RemoveAnimationCurve_WithConfirm_Removes()
|
||||
{
|
||||
var path = Root + "/Clips/Remove2.anim";
|
||||
AnimationClipCommands.CreateAnimationClip(path);
|
||||
AnimationClipCommands.SetAnimationCurve(PathRef(path), "", "Transform", "m_LocalPosition.x", TwoKeys());
|
||||
|
||||
AnimationClipCommands.RemoveAnimationCurve(PathRef(path), "", "Transform", "m_LocalPosition.x", confirm: true);
|
||||
|
||||
var info = AnimationClipCommands.GetAnimationClip(PathRef(path));
|
||||
Assert.AreEqual(0, info.Bindings.Count, "binding should be gone after a confirmed remove");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetAnimationCurve_UnknownType_Throws()
|
||||
{
|
||||
var path = Root + "/Clips/BadType.anim";
|
||||
AnimationClipCommands.CreateAnimationClip(path);
|
||||
|
||||
Assert.Throws<System.ArgumentException>(
|
||||
() => AnimationClipCommands.SetAnimationCurve(PathRef(path), "", "NotARealComponent__CLI214", "m_X", TwoKeys()));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetAnimationCurve_ViaClient_Succeeds()
|
||||
{
|
||||
var path = Root + "/ViaClient/Curve.anim";
|
||||
AnimationClipCommands.CreateAnimationClip(path);
|
||||
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("set_animation_curve", new
|
||||
{
|
||||
clip = new { path },
|
||||
type = "Transform",
|
||||
property = "m_LocalPosition.x",
|
||||
keys = new object[]
|
||||
{
|
||||
new { time = 0f, value = 0f },
|
||||
new { time = 1f, value = 5f }
|
||||
}
|
||||
});
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, $"set_animation_curve should succeed: {response.Error}");
|
||||
}
|
||||
|
||||
var info = AnimationClipCommands.GetAnimationClip(PathRef(path));
|
||||
Assert.AreEqual(1, info.Bindings.Count);
|
||||
Assert.AreEqual(2, info.Bindings[0].KeyCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
+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>
|
||||
class AnimatorControllerCommandsTests
|
||||
{
|
||||
private const string Root = "Assets/__CLI214CtrlTest";
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
ProjectPaths.ResetAuthoringRoot();
|
||||
if (AssetDatabase.IsValidFolder(Root))
|
||||
{
|
||||
AssetDatabase.DeleteAsset(Root);
|
||||
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||
}
|
||||
}
|
||||
|
||||
private static ObjectRef PathRef(string path) => new ObjectRef { Path = path };
|
||||
|
||||
private string CreateController(string name)
|
||||
{
|
||||
var path = $"{Root}/{name}.controller";
|
||||
AnimatorControllerCommands.CreateAnimatorController(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateController_HasOneBaseLayerNoStates()
|
||||
{
|
||||
var path = CreateController("Empty");
|
||||
|
||||
var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path));
|
||||
Assert.AreEqual(1, info.Layers.Count, "A new controller should have exactly one (Base) layer");
|
||||
Assert.AreEqual(0, info.Layers[0].States.Count, "Base Layer should start with no states");
|
||||
Assert.AreEqual(0, info.Parameters.Count);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddParameter_Float_AndDuplicate()
|
||||
{
|
||||
var path = CreateController("Params");
|
||||
|
||||
var added = AnimatorControllerCommands.AddAnimatorParameter(PathRef(path), "Speed", "Float", new JValue(0f));
|
||||
Assert.IsInstanceOf<AddParameterResult>(added);
|
||||
|
||||
var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path));
|
||||
Assert.AreEqual(1, info.Parameters.Count);
|
||||
Assert.AreEqual("Speed", info.Parameters[0].Name);
|
||||
Assert.AreEqual("Float", info.Parameters[0].ParamType);
|
||||
|
||||
// Duplicate name => structured error, not exception.
|
||||
var dup = AnimatorControllerCommands.AddAnimatorParameter(PathRef(path), "Speed", "Float");
|
||||
Assert.IsInstanceOf<ErrorResult>(dup);
|
||||
Assert.AreEqual("duplicate_parameter", ((ErrorResult)dup).Code);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddLayer_AppendsLayer()
|
||||
{
|
||||
var path = CreateController("Layers");
|
||||
|
||||
var added = AnimatorControllerCommands.AddAnimatorLayer(PathRef(path), "Upper", weight: 0.5f, blendingMode: "Additive");
|
||||
Assert.IsInstanceOf<AddLayerResult>(added);
|
||||
Assert.AreEqual(1, ((AddLayerResult)added).Layer.Index);
|
||||
|
||||
var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path));
|
||||
Assert.AreEqual(2, info.Layers.Count);
|
||||
Assert.AreEqual("Upper", info.Layers[1].Name);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddState_WithMotionAndDefault()
|
||||
{
|
||||
var path = CreateController("States");
|
||||
|
||||
// A clip to use as the state motion.
|
||||
var clipPath = Root + "/Idle.anim";
|
||||
AnimationClipCommands.CreateAnimationClip(clipPath);
|
||||
|
||||
var added = AnimatorControllerCommands.AddAnimatorState(
|
||||
PathRef(path), layer: null, name: "Idle", motion: PathRef(clipPath), isDefault: true);
|
||||
Assert.IsInstanceOf<AddStateResult>(added);
|
||||
|
||||
var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path));
|
||||
Assert.AreEqual(1, info.Layers[0].States.Count);
|
||||
var state = info.Layers[0].States[0];
|
||||
Assert.AreEqual("Idle", state.Name);
|
||||
Assert.IsTrue(state.IsDefault, "Idle should be the layer default");
|
||||
Assert.IsNotNull(state.Motion, "Idle should have a non-null motion");
|
||||
Assert.AreEqual("Idle", info.Layers[0].DefaultState);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddState_UnknownLayerName_ReturnsLayerNotFound()
|
||||
{
|
||||
var path = CreateController("BadLayer");
|
||||
|
||||
var result = AnimatorControllerCommands.AddAnimatorState(
|
||||
PathRef(path), layer: new JValue("NoSuchLayer"), name: "S");
|
||||
Assert.IsInstanceOf<ErrorResult>(result);
|
||||
Assert.AreEqual("layer_not_found", ((ErrorResult)result).Code);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddTransition_WithCondition()
|
||||
{
|
||||
var path = CreateController("Transitions");
|
||||
AnimatorControllerCommands.AddAnimatorParameter(PathRef(path), "Speed", "Float", new JValue(0f));
|
||||
AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Idle");
|
||||
AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Run");
|
||||
|
||||
var conditions = new JArray
|
||||
{
|
||||
new JObject { ["parameter"] = "Speed", ["mode"] = "Greater", ["threshold"] = 0.1f }
|
||||
};
|
||||
var added = AnimatorControllerCommands.AddAnimatorTransition(
|
||||
PathRef(path), null, "Idle", "Run", conditions);
|
||||
Assert.IsInstanceOf<AddTransitionResult>(added);
|
||||
Assert.AreEqual(1, ((AddTransitionResult)added).Transition.ConditionCount);
|
||||
|
||||
var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path));
|
||||
var transitions = info.Layers[0].Transitions;
|
||||
Assert.AreEqual(1, transitions.Count);
|
||||
Assert.AreEqual("Idle", transitions[0].From);
|
||||
Assert.AreEqual("Run", transitions[0].To);
|
||||
Assert.AreEqual(1, transitions[0].Conditions.Count);
|
||||
Assert.AreEqual("Speed", transitions[0].Conditions[0].Parameter);
|
||||
Assert.AreEqual("Greater", transitions[0].Conditions[0].Mode);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddTransition_UnknownParameter_ThrowsAndWritesNothing()
|
||||
{
|
||||
var path = CreateController("BadParam");
|
||||
AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Idle");
|
||||
AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Run");
|
||||
|
||||
var conditions = new JArray
|
||||
{
|
||||
new JObject { ["parameter"] = "Ghost", ["mode"] = "Greater", ["threshold"] = 0.1f }
|
||||
};
|
||||
|
||||
var ex = Assert.Throws<System.ArgumentException>(
|
||||
() => AnimatorControllerCommands.AddAnimatorTransition(PathRef(path), null, "Idle", "Run", conditions));
|
||||
StringAssert.Contains("Ghost", ex.Message, "error should name the offending parameter");
|
||||
|
||||
var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path));
|
||||
Assert.AreEqual(0, info.Layers[0].Transitions.Count, "nothing should be written on a bad condition");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddTransition_ModeTypeMismatch_Throws()
|
||||
{
|
||||
var path = CreateController("Mismatch");
|
||||
AnimatorControllerCommands.AddAnimatorParameter(PathRef(path), "Speed", "Float", new JValue(0f));
|
||||
AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Idle");
|
||||
AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Run");
|
||||
|
||||
// "If" is only valid for Bool/Trigger, not a Float parameter.
|
||||
var conditions = new JArray
|
||||
{
|
||||
new JObject { ["parameter"] = "Speed", ["mode"] = "If" }
|
||||
};
|
||||
|
||||
Assert.Throws<System.ArgumentException>(
|
||||
() => AnimatorControllerCommands.AddAnimatorTransition(PathRef(path), null, "Idle", "Run", conditions));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddState_FractionalLayerIndex_ReturnsLayerNotFound()
|
||||
{
|
||||
var path = CreateController("FractionalLayer");
|
||||
|
||||
// 0.9 must NOT be silently rounded to layer 1; a non-integer index is rejected.
|
||||
var result = AnimatorControllerCommands.AddAnimatorState(
|
||||
PathRef(path), layer: new JValue(0.9d), name: "S");
|
||||
Assert.IsInstanceOf<ErrorResult>(result);
|
||||
Assert.AreEqual("layer_not_found", ((ErrorResult)result).Code);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddState_IntegerValuedFloatLayer_Resolves()
|
||||
{
|
||||
var path = CreateController("IntFloatLayer");
|
||||
|
||||
// 0.0 is an integer-valued float and resolves to the Base Layer (index 0).
|
||||
var added = AnimatorControllerCommands.AddAnimatorState(
|
||||
PathRef(path), layer: new JValue(0d), name: "Idle");
|
||||
Assert.IsInstanceOf<AddStateResult>(added);
|
||||
Assert.AreEqual(0, ((AddStateResult)added).State.Layer);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddTransition_ExitTimeOutOfRange_Throws()
|
||||
{
|
||||
var path = CreateController("BadExitTime");
|
||||
AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Idle");
|
||||
AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Run");
|
||||
|
||||
Assert.Throws<System.ArgumentException>(
|
||||
() => AnimatorControllerCommands.AddAnimatorTransition(
|
||||
PathRef(path), null, "Idle", "Run", conditions: null, hasExitTime: true, exitTime: 1.5f));
|
||||
|
||||
var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path));
|
||||
Assert.AreEqual(0, info.Layers[0].Transitions.Count, "an out-of-range exitTime must write nothing");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddTransition_NegativeDuration_Throws()
|
||||
{
|
||||
var path = CreateController("BadDuration");
|
||||
AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Idle");
|
||||
AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Run");
|
||||
|
||||
Assert.Throws<System.ArgumentException>(
|
||||
() => AnimatorControllerCommands.AddAnimatorTransition(
|
||||
PathRef(path), null, "Idle", "Run", conditions: null, duration: -1f));
|
||||
|
||||
var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path));
|
||||
Assert.AreEqual(0, info.Layers[0].Transitions.Count, "a negative duration must write nothing");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddParameter_DryRun_WritesNothing()
|
||||
{
|
||||
var path = CreateController("DryParam");
|
||||
|
||||
AnimatorControllerCommands.AddAnimatorParameter(PathRef(path), "Speed", "Float", new JValue(0f), dryRun: true);
|
||||
|
||||
var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path));
|
||||
Assert.AreEqual(0, info.Parameters.Count, "dry_run must not write a parameter");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateController_ViaClient_Succeeds()
|
||||
{
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var path = Root + "/ViaClient/Made.controller";
|
||||
var response = server.Execute("create_animator_controller", new { path });
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, $"create_animator_controller should succeed: {response.Error}");
|
||||
Assert.IsNotNull(AssetDatabase.LoadMainAssetAtPath(path), "Controller should exist on disk");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c1d78b8f19fc48cea840bc62c3204b3e
|
||||
@@ -0,0 +1,81 @@
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Editor.Commands.Animation;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor.Animation
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for CLI-214 Group C (Timeline). Timeline ships in the OPTIONAL com.unity.timeline package,
|
||||
/// so this fixture must COMPILE and PASS whether or not the package is installed:
|
||||
/// - <see cref="Timeline_NotInstalled_ReturnsPackageNotFound"/> runs only when the package is ABSENT
|
||||
/// and asserts the structured package_not_found error (no exception).
|
||||
/// - the create -> add track -> add clip -> get flow runs only when the package is PRESENT and
|
||||
/// calls <c>Assert.Pass</c> (not <c>Assert.Ignore</c>) when absent, so Yamato strict-mode
|
||||
/// agents see "Passed" rather than "Not Run" (Inconclusive) for the conditional skip.
|
||||
///
|
||||
/// All commands are reached through the public static methods; the Timeline types themselves are
|
||||
/// only touched via reflection inside the commands, never referenced by this test assembly.
|
||||
/// </summary>
|
||||
class TimelineCommandsTests
|
||||
{
|
||||
private const string Root = "Assets/__CLI214TimelineTest";
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
ProjectPaths.ResetAuthoringRoot();
|
||||
if (AssetDatabase.IsValidFolder(Root))
|
||||
{
|
||||
AssetDatabase.DeleteAsset(Root);
|
||||
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||
}
|
||||
}
|
||||
|
||||
private static ObjectRef PathRef(string path) => new ObjectRef { Path = path };
|
||||
|
||||
[Test]
|
||||
public void Timeline_NotInstalled_ReturnsPackageNotFound()
|
||||
{
|
||||
if (TimelineGuard.IsInstalled())
|
||||
Assert.Pass("com.unity.timeline is installed; the not-installed path is covered only when it is absent.");
|
||||
|
||||
var result = TimelineCommands.CreateTimeline(Root + "/T.playable");
|
||||
Assert.IsInstanceOf<ErrorResult>(result, "Timeline commands must not throw when the package is absent");
|
||||
Assert.AreEqual("package_not_found", ((ErrorResult)result).Code);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Timeline_CreateTrackClipGet_Flow()
|
||||
{
|
||||
if (!TimelineGuard.IsInstalled())
|
||||
Assert.Pass("com.unity.timeline is not installed; the Timeline authoring flow is validated only when the package is present.");
|
||||
|
||||
// create_timeline
|
||||
var timelinePath = Root + "/Cutscene.playable";
|
||||
var created = TimelineCommands.CreateTimeline(timelinePath, frameRate: 30f);
|
||||
Assert.IsInstanceOf<AuthoringResult>(created, "create_timeline should return an AuthoringResult when installed");
|
||||
|
||||
// add an Animation track
|
||||
var trackResult = TimelineCommands.AddTimelineTrack(PathRef(timelinePath), "Animation", name: "Anim");
|
||||
Assert.IsInstanceOf<AddTimelineTrackResult>(trackResult);
|
||||
|
||||
// a clip asset for the Animation track
|
||||
var clipPath = Root + "/Move.anim";
|
||||
AnimationClipCommands.CreateAnimationClip(clipPath);
|
||||
|
||||
// add_timeline_clip (start 0, duration 2)
|
||||
var clipResult = TimelineCommands.AddTimelineClip(
|
||||
PathRef(timelinePath), track: "Anim", start: 0f, duration: 2f, asset: PathRef(clipPath));
|
||||
Assert.IsInstanceOf<AddTimelineClipResult>(clipResult);
|
||||
|
||||
// get_timeline => one Animation track with one 2s clip
|
||||
var info = (TimelineInfo)TimelineCommands.GetTimeline(PathRef(timelinePath));
|
||||
Assert.AreEqual(1, info.Tracks.Count, "expected exactly one track");
|
||||
Assert.AreEqual("Animation", info.Tracks[0].TrackType);
|
||||
Assert.AreEqual(1, info.Tracks[0].Clips.Count, "expected exactly one clip");
|
||||
Assert.AreEqual(2d, info.Tracks[0].Clips[0].Duration, 0.001d);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 72d8c657533f4b8b96feb3af780d619a
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fddeee399ff1c104d82a62adbac36d43
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7571005014bc4727a0ef06b2056351a2
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,246 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Editor.Commands.Assets;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor.Assets
|
||||
{
|
||||
/// <summary>
|
||||
/// A trivial ScriptableObject used by the asset-command tests to exercise create / find / move /
|
||||
/// delete with a real, project-resolvable type.
|
||||
/// </summary>
|
||||
class CLI191SampleAsset : ScriptableObject
|
||||
{
|
||||
public int Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the CLI-191 asset lifecycle commands, exercised both directly (static method) and via
|
||||
/// the isolated <see cref="PipelineTestServer"/>. Covers the acceptance flow (create folder tree →
|
||||
/// create ScriptableObject → find by type → move → delete), the sandbox guard (out-of-project
|
||||
/// writes rejected), and the destructive guard (delete without confirm rejected).
|
||||
/// </summary>
|
||||
class AssetCommandsTests
|
||||
{
|
||||
private const string Root = "Assets/__CLI191Test";
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
ProjectPaths.ResetAuthoringRoot();
|
||||
if (AssetDatabase.IsValidFolder(Root))
|
||||
{
|
||||
AssetDatabase.DeleteAsset(Root);
|
||||
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||
}
|
||||
}
|
||||
|
||||
private static ObjectRef PathRef(string path) => new ObjectRef { Path = path };
|
||||
|
||||
#region Direct
|
||||
|
||||
[Test]
|
||||
public void CreateAsset_ScriptableObject_CreatesAssetAndReturnsIdentity()
|
||||
{
|
||||
var path = Root + "/Data/Sample.asset";
|
||||
var result = AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName);
|
||||
|
||||
Assert.AreEqual(path, result.AssetPath);
|
||||
Assert.IsNotEmpty(result.Guid, "Created asset should have a GUID");
|
||||
Assert.IsNotNull(AssetDatabase.LoadAssetAtPath<CLI191SampleAsset>(path), "Asset should exist on disk");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateAsset_OverwriteWithoutConfirm_Throws()
|
||||
{
|
||||
var path = Root + "/Data/Dup.asset";
|
||||
AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName);
|
||||
|
||||
Assert.Throws<System.ArgumentException>(
|
||||
() => AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName),
|
||||
"Overwriting an existing asset without confirm should be rejected");
|
||||
|
||||
// With confirm it succeeds.
|
||||
var result = AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName, confirm: true);
|
||||
Assert.AreEqual(path, result.AssetPath);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateAsset_OutOfProject_Throws()
|
||||
{
|
||||
Assert.Throws<System.ArgumentException>(
|
||||
() => AssetCommands.CreateAsset("../Outside.asset", typeof(CLI191SampleAsset).FullName));
|
||||
Assert.Throws<System.ArgumentException>(
|
||||
() => AssetCommands.CreateAsset("Assets/../Outside.asset", typeof(CLI191SampleAsset).FullName));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FindAssets_ByType_FindsCreatedAsset()
|
||||
{
|
||||
var path = Root + "/Find/Target.asset";
|
||||
AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName);
|
||||
|
||||
var found = AssetCommands.FindAssets(type: nameof(CLI191SampleAsset), searchIn: Root);
|
||||
|
||||
Assert.GreaterOrEqual(found.Count, 1, "Should find the created asset by type");
|
||||
CollectionAssert.Contains(
|
||||
System.Linq.Enumerable.Select(found.Assets, a => a.AssetPath),
|
||||
path);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FindAssets_NoFilter_Throws()
|
||||
{
|
||||
Assert.Throws<System.ArgumentException>(() => AssetCommands.FindAssets());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MoveAsset_RelocatesAndPreservesGuid()
|
||||
{
|
||||
var src = Root + "/Move/Src.asset";
|
||||
AssetCommands.CreateAsset(src, typeof(CLI191SampleAsset).FullName);
|
||||
var srcGuid = AssetDatabase.AssetPathToGUID(src);
|
||||
|
||||
var dest = Root + "/Move/Dest/Moved.asset";
|
||||
var result = AssetCommands.MoveAsset(PathRef(src), dest);
|
||||
|
||||
Assert.AreEqual(dest, result.AssetPath);
|
||||
Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(src), "Source should no longer exist");
|
||||
Assert.AreEqual(srcGuid, AssetDatabase.AssetPathToGUID(dest), "GUID should be preserved across a move");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CopyAsset_CreatesDistinctAsset()
|
||||
{
|
||||
var src = Root + "/Copy/Src.asset";
|
||||
AssetCommands.CreateAsset(src, typeof(CLI191SampleAsset).FullName);
|
||||
|
||||
var dest = Root + "/Copy/Copy.asset";
|
||||
var result = AssetCommands.CopyAsset(PathRef(src), dest);
|
||||
|
||||
Assert.AreEqual(dest, result.AssetPath);
|
||||
Assert.IsNotNull(AssetDatabase.LoadMainAssetAtPath(src), "Source should still exist after copy");
|
||||
Assert.AreNotEqual(AssetDatabase.AssetPathToGUID(src), AssetDatabase.AssetPathToGUID(dest),
|
||||
"A copy should get a fresh GUID");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RenameAsset_RenamesInPlace()
|
||||
{
|
||||
var src = Root + "/Rename/Old.asset";
|
||||
AssetCommands.CreateAsset(src, typeof(CLI191SampleAsset).FullName);
|
||||
|
||||
var result = AssetCommands.RenameAsset(PathRef(src), "New");
|
||||
|
||||
Assert.AreEqual(Root + "/Rename/New.asset", result.AssetPath);
|
||||
Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(src));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DeleteAsset_WithoutConfirm_Throws()
|
||||
{
|
||||
var path = Root + "/Delete/Doomed.asset";
|
||||
AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName);
|
||||
|
||||
Assert.Throws<System.ArgumentException>(() => AssetCommands.DeleteAsset(PathRef(path)),
|
||||
"delete_asset without confirm should be rejected");
|
||||
Assert.IsNotNull(AssetDatabase.LoadMainAssetAtPath(path), "Asset should survive a refused delete");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DeleteAsset_WithConfirm_Deletes()
|
||||
{
|
||||
var path = Root + "/Delete/Doomed2.asset";
|
||||
AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName);
|
||||
|
||||
var result = AssetCommands.DeleteAsset(PathRef(path), confirm: true);
|
||||
|
||||
Assert.AreEqual(path, result.AssetPath);
|
||||
Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(path), "Asset should be gone after a confirmed delete");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AcceptanceFlow_CreateFindMoveDelete()
|
||||
{
|
||||
// create folder tree
|
||||
FolderCommands.CreateFolder(Root + "/Flow/Configs");
|
||||
Assert.IsTrue(AssetDatabase.IsValidFolder(Root + "/Flow/Configs"));
|
||||
|
||||
// create ScriptableObject asset
|
||||
var path = Root + "/Flow/Configs/Cfg.asset";
|
||||
AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName);
|
||||
|
||||
// find by type
|
||||
var found = AssetCommands.FindAssets(type: nameof(CLI191SampleAsset), searchIn: Root + "/Flow");
|
||||
Assert.GreaterOrEqual(found.Count, 1);
|
||||
|
||||
// move it
|
||||
var moved = AssetCommands.MoveAsset(PathRef(path), Root + "/Flow/Moved.asset");
|
||||
Assert.AreEqual(Root + "/Flow/Moved.asset", moved.AssetPath);
|
||||
|
||||
// delete it (with confirm)
|
||||
AssetCommands.DeleteAsset(PathRef(moved.AssetPath), confirm: true);
|
||||
Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(moved.AssetPath));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ViaClient
|
||||
|
||||
[Test]
|
||||
public void CreateAsset_ViaClient_Succeeds()
|
||||
{
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var path = Root + "/ViaClient/Made.asset";
|
||||
var response = server.Execute("create_asset", new { path, type = typeof(CLI191SampleAsset).FullName });
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, $"create_asset should succeed: {response.Error}");
|
||||
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
|
||||
Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field");
|
||||
Assert.IsNotNull(AssetDatabase.LoadAssetAtPath<CLI191SampleAsset>(path), "Asset should exist");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DeleteAsset_ViaClient_WithoutConfirm_Fails()
|
||||
{
|
||||
var path = Root + "/ViaClient/ToDelete.asset";
|
||||
AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName);
|
||||
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
// The server logs the refusal as a Unity [Error]; expect it so the test does not fail on
|
||||
// the unexpected log. Matches the message thrown by DeleteAsset ("Refusing to delete ...").
|
||||
LogAssert.Expect(LogType.Error, new Regex("Refusing to delete"));
|
||||
|
||||
var response = server.Execute("delete_asset", new { asset = new { path } });
|
||||
|
||||
Assert.IsFalse(response.IsSuccess, "delete_asset without confirm should fail over the wire");
|
||||
Assert.IsNotNull(AssetDatabase.LoadMainAssetAtPath(path), "Asset should survive a refused delete");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FindAssets_ViaClient_ReturnsResults()
|
||||
{
|
||||
var path = Root + "/ViaClient/Findable.asset";
|
||||
AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName);
|
||||
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("find_assets", new { type = nameof(CLI191SampleAsset), search_in = Root });
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, $"find_assets should succeed: {response.Error}");
|
||||
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
|
||||
Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d4736a7747a847edb2355c95559ce0b0
|
||||
+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>
|
||||
class AssetImportCommandsTests
|
||||
{
|
||||
private const string Root = "Assets/__CLI191ImportTest";
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
ProjectPaths.ResetAuthoringRoot();
|
||||
if (AssetDatabase.IsValidFolder(Root))
|
||||
{
|
||||
AssetDatabase.DeleteAsset(Root);
|
||||
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||
}
|
||||
}
|
||||
|
||||
private static ObjectRef PathRef(string path) => new ObjectRef { Path = path };
|
||||
|
||||
private static string CreateSampleAsset()
|
||||
{
|
||||
var path = Root + "/Imported.asset";
|
||||
AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName);
|
||||
return path;
|
||||
}
|
||||
|
||||
#region Direct
|
||||
|
||||
[Test]
|
||||
public void SetImportSettings_AppliesKnownMember()
|
||||
{
|
||||
var path = CreateSampleAsset();
|
||||
var settings = new JObject { ["userData"] = "cli191" };
|
||||
|
||||
var result = AssetImportCommands.SetImportSettings(PathRef(path), settings);
|
||||
|
||||
CollectionAssert.Contains(result.Applied, "userData");
|
||||
CollectionAssert.IsEmpty(result.Unknown);
|
||||
Assert.AreEqual("cli191", AssetImporter.GetAtPath(path).userData, "Setting should persist on the importer");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetImportSettings_UnknownOnly_Throws()
|
||||
{
|
||||
var path = CreateSampleAsset();
|
||||
var settings = new JObject { ["definitelyNotAMember"] = 42 };
|
||||
|
||||
Assert.Throws<System.ArgumentException>(
|
||||
() => AssetImportCommands.SetImportSettings(PathRef(path), settings),
|
||||
"When no supplied setting exists on the importer, the command should fail");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetImportSettings_DryRun_DoesNotPersist()
|
||||
{
|
||||
var path = CreateSampleAsset();
|
||||
var settings = new JObject { ["userData"] = "dryrun" };
|
||||
|
||||
var result = AssetImportCommands.SetImportSettings(PathRef(path), settings, dryRun: true);
|
||||
|
||||
// dry_run reports the member as applicable but never mutates the importer.
|
||||
CollectionAssert.Contains(result.Applied, "userData");
|
||||
Assert.AreNotEqual("dryrun", AssetImporter.GetAtPath(path).userData, "dry_run should not persist the change");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ViaClient
|
||||
|
||||
[Test]
|
||||
public void SetImportSettings_ViaClient_Succeeds()
|
||||
{
|
||||
var path = CreateSampleAsset();
|
||||
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("set_import_settings", new
|
||||
{
|
||||
asset = new { path },
|
||||
settings = new { userData = "viaclient" }
|
||||
});
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, $"set_import_settings should succeed: {response.Error}");
|
||||
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
|
||||
Assert.AreEqual("viaclient", AssetImporter.GetAtPath(path).userData);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
+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>
|
||||
class ImportSettingsCommandTests
|
||||
{
|
||||
private const string Root = "Assets/__CLI212ImportTest";
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
ProjectPaths.ResetAuthoringRoot();
|
||||
if (AssetDatabase.IsValidFolder(Root))
|
||||
{
|
||||
AssetDatabase.DeleteAsset(Root);
|
||||
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||
}
|
||||
}
|
||||
|
||||
private static ObjectRef PathRef(string path) => new ObjectRef { Path = path };
|
||||
|
||||
// ------------------------------------------------------------------ asset fixtures
|
||||
|
||||
/// <summary>Write a tiny 4x4 PNG so Unity imports it with a TextureImporter.</summary>
|
||||
private static string CreateTexture(string name = "Tex.png")
|
||||
{
|
||||
EnsureRoot();
|
||||
var tex = new Texture2D(4, 4, TextureFormat.RGBA32, false);
|
||||
var pixels = new Color32[16];
|
||||
for (var i = 0; i < pixels.Length; i++)
|
||||
pixels[i] = new Color32(255, 128, 64, 255);
|
||||
tex.SetPixels32(pixels);
|
||||
tex.Apply();
|
||||
var bytes = tex.EncodeToPNG();
|
||||
Object.DestroyImmediate(tex);
|
||||
|
||||
var projectPath = Root + "/" + name;
|
||||
File.WriteAllBytes(ToAbsolute(projectPath), bytes);
|
||||
AssetDatabase.ImportAsset(projectPath, ImportAssetOptions.ForceSynchronousImport);
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
/// <summary>Write a minimal single-triangle OBJ so Unity imports it with a ModelImporter.</summary>
|
||||
private static string CreateModel(string name = "Tri.obj")
|
||||
{
|
||||
EnsureRoot();
|
||||
const string obj =
|
||||
"o Tri\n" +
|
||||
"v 0 0 0\n" +
|
||||
"v 1 0 0\n" +
|
||||
"v 0 1 0\n" +
|
||||
"vn 0 0 1\n" +
|
||||
"f 1//1 2//1 3//1\n";
|
||||
var projectPath = Root + "/" + name;
|
||||
File.WriteAllText(ToAbsolute(projectPath), obj);
|
||||
AssetDatabase.ImportAsset(projectPath, ImportAssetOptions.ForceSynchronousImport);
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write a tiny mono 8-bit PCM WAV (a handful of samples) so Unity imports it with an
|
||||
/// AudioImporter — enough to exercise the real per-platform override read/write paths.
|
||||
/// </summary>
|
||||
private static string CreateAudio(string name = "Beep.wav")
|
||||
{
|
||||
EnsureRoot();
|
||||
|
||||
const int sampleRate = 8000;
|
||||
const int sampleCount = 64;
|
||||
var pcm = new byte[sampleCount];
|
||||
for (var i = 0; i < pcm.Length; i++)
|
||||
pcm[i] = (byte)(128 + (i % 2 == 0 ? 32 : -32)); // crude square wave around the 8-bit midpoint
|
||||
|
||||
using (var stream = new MemoryStream())
|
||||
using (var w = new BinaryWriter(stream))
|
||||
{
|
||||
const short channels = 1;
|
||||
const short bitsPerSample = 8;
|
||||
var byteRate = sampleRate * channels * bitsPerSample / 8;
|
||||
var blockAlign = (short)(channels * bitsPerSample / 8);
|
||||
|
||||
// RIFF header
|
||||
w.Write(new[] { 'R', 'I', 'F', 'F' });
|
||||
w.Write(36 + pcm.Length);
|
||||
w.Write(new[] { 'W', 'A', 'V', 'E' });
|
||||
// fmt chunk
|
||||
w.Write(new[] { 'f', 'm', 't', ' ' });
|
||||
w.Write(16); // PCM fmt chunk size
|
||||
w.Write((short)1); // audio format = PCM
|
||||
w.Write(channels);
|
||||
w.Write(sampleRate);
|
||||
w.Write(byteRate);
|
||||
w.Write(blockAlign);
|
||||
w.Write(bitsPerSample);
|
||||
// data chunk
|
||||
w.Write(new[] { 'd', 'a', 't', 'a' });
|
||||
w.Write(pcm.Length);
|
||||
w.Write(pcm);
|
||||
w.Flush();
|
||||
|
||||
var projectPath = Root + "/" + name;
|
||||
File.WriteAllBytes(ToAbsolute(projectPath), stream.ToArray());
|
||||
AssetDatabase.ImportAsset(projectPath, ImportAssetOptions.ForceSynchronousImport);
|
||||
return projectPath;
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureRoot()
|
||||
{
|
||||
if (!AssetDatabase.IsValidFolder(Root))
|
||||
AssetDatabase.CreateFolder("Assets", "__CLI212ImportTest");
|
||||
}
|
||||
|
||||
private static string ToAbsolute(string projectRelative) =>
|
||||
$"{ProjectPaths.ProjectRoot.Replace('\\', '/')}/{projectRelative}";
|
||||
|
||||
// ------------------------------------------------------------------ texture: read
|
||||
|
||||
[Test]
|
||||
public void GetImportSettings_Texture_ReturnsStructuredDefaultsAndOverrideBlock()
|
||||
{
|
||||
var path = CreateTexture();
|
||||
|
||||
var result = AssetImportCommands.GetImportSettings(PathRef(path), platform: "Android");
|
||||
|
||||
Assert.AreEqual("TextureImporter", result.ImporterType);
|
||||
Assert.AreEqual("Android", result.Platform);
|
||||
Assert.IsNotNull(result.Settings);
|
||||
Assert.IsTrue(result.Settings.ContainsKey("textureType"));
|
||||
Assert.IsTrue(result.Settings.ContainsKey("isReadable"));
|
||||
Assert.IsTrue(result.Settings.ContainsKey("mipmapEnabled"));
|
||||
Assert.IsTrue(result.Settings.ContainsKey("wrapMode"));
|
||||
Assert.IsNotNull(result.PlatformOverride, "Texture should expose a platform override block");
|
||||
Assert.IsTrue(result.PlatformOverride.ContainsKey("overridden"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ texture: default round-trip
|
||||
|
||||
[Test]
|
||||
public void SetImportSettings_TextureDefault_AppliesAndReadBackReflects()
|
||||
{
|
||||
var path = CreateTexture();
|
||||
|
||||
var settings = new JObject
|
||||
{
|
||||
["isReadable"] = true,
|
||||
["textureType"] = "NormalMap"
|
||||
};
|
||||
var set = AssetImportCommands.SetImportSettings(PathRef(path), settings);
|
||||
|
||||
CollectionAssert.Contains(set.Applied, "isReadable");
|
||||
CollectionAssert.Contains(set.Applied, "textureType");
|
||||
CollectionAssert.IsEmpty(set.Unknown);
|
||||
|
||||
var read = AssetImportCommands.GetImportSettings(PathRef(path));
|
||||
Assert.AreEqual(true, read.Settings["isReadable"]);
|
||||
Assert.AreEqual("NormalMap", read.Settings["textureType"]);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ texture: platform override set + clear
|
||||
|
||||
[Test]
|
||||
public void SetImportSettings_TextureAndroidOverride_SetsThenClears()
|
||||
{
|
||||
var path = CreateTexture();
|
||||
|
||||
// Default platform maxTextureSize before the override (should be unchanged afterwards).
|
||||
var defaultBefore = AssetImportCommands.GetImportSettings(PathRef(path));
|
||||
var defaultMaxBefore = defaultBefore.Settings["maxTextureSize"];
|
||||
|
||||
var set = AssetImportCommands.SetImportSettings(
|
||||
PathRef(path),
|
||||
new JObject { ["maxTextureSize"] = 1024, ["format"] = "ASTC_6x6", ["overridden"] = true },
|
||||
platform: "Android");
|
||||
CollectionAssert.Contains(set.Applied, "maxTextureSize");
|
||||
CollectionAssert.Contains(set.Applied, "format");
|
||||
|
||||
var afterSet = AssetImportCommands.GetImportSettings(PathRef(path), platform: "Android");
|
||||
Assert.AreEqual(true, afterSet.PlatformOverride["overridden"]);
|
||||
Assert.AreEqual(1024, afterSet.PlatformOverride["maxTextureSize"]);
|
||||
Assert.AreEqual("ASTC_6x6", afterSet.PlatformOverride["format"]);
|
||||
|
||||
// Default platform unchanged by the Android override.
|
||||
var defaultAfter = AssetImportCommands.GetImportSettings(PathRef(path));
|
||||
Assert.AreEqual(defaultMaxBefore, defaultAfter.Settings["maxTextureSize"],
|
||||
"Default platform settings must be unchanged by a platform override");
|
||||
|
||||
// Clear the override.
|
||||
AssetImportCommands.SetImportSettings(
|
||||
PathRef(path),
|
||||
new JObject { ["overridden"] = false },
|
||||
platform: "Android");
|
||||
|
||||
var afterClear = AssetImportCommands.GetImportSettings(PathRef(path), platform: "Android");
|
||||
Assert.AreEqual(false, afterClear.PlatformOverride["overridden"], "Override should be cleared");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ audio: read
|
||||
|
||||
[Test]
|
||||
public void GetImportSettings_Audio_ReturnsStructuredDefaultsAndOverrideBlock()
|
||||
{
|
||||
var path = CreateAudio();
|
||||
|
||||
var result = AssetImportCommands.GetImportSettings(PathRef(path), platform: "Android");
|
||||
|
||||
Assert.AreEqual("AudioImporter", result.ImporterType);
|
||||
Assert.AreEqual("Android", result.Platform);
|
||||
Assert.IsNotNull(result.Settings);
|
||||
Assert.IsTrue(result.Settings.ContainsKey("loadType"));
|
||||
Assert.IsTrue(result.Settings.ContainsKey("compressionFormat"));
|
||||
Assert.IsTrue(result.Settings.ContainsKey("quality"));
|
||||
Assert.IsNotNull(result.PlatformOverride, "Audio should expose a platform override block");
|
||||
Assert.IsTrue(result.PlatformOverride.ContainsKey("overridden"));
|
||||
// No override has been written yet.
|
||||
Assert.AreEqual(false, result.PlatformOverride["overridden"]);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ audio: platform override set + clear
|
||||
|
||||
[Test]
|
||||
public void SetImportSettings_AudioAndroidOverride_SetsThenClears()
|
||||
{
|
||||
var path = CreateAudio();
|
||||
|
||||
// Default-platform compressionFormat before the override (must be unchanged afterwards).
|
||||
var defaultBefore = AssetImportCommands.GetImportSettings(PathRef(path));
|
||||
var defaultCompressionBefore = defaultBefore.Settings["compressionFormat"];
|
||||
|
||||
var set = AssetImportCommands.SetImportSettings(
|
||||
PathRef(path),
|
||||
new JObject { ["compressionFormat"] = "Vorbis", ["loadType"] = "CompressedInMemory", ["overridden"] = true },
|
||||
platform: "Android");
|
||||
CollectionAssert.Contains(set.Applied, "compressionFormat");
|
||||
CollectionAssert.Contains(set.Applied, "loadType");
|
||||
CollectionAssert.Contains(set.Applied, "overridden");
|
||||
CollectionAssert.IsEmpty(set.Unknown);
|
||||
|
||||
var afterSet = AssetImportCommands.GetImportSettings(PathRef(path), platform: "Android");
|
||||
Assert.AreEqual(true, afterSet.PlatformOverride["overridden"], "Override should be present after a platform write");
|
||||
Assert.AreEqual("Vorbis", afterSet.PlatformOverride["compressionFormat"]);
|
||||
Assert.AreEqual("CompressedInMemory", afterSet.PlatformOverride["loadType"]);
|
||||
|
||||
// Default platform unchanged by the Android override.
|
||||
var defaultAfter = AssetImportCommands.GetImportSettings(PathRef(path));
|
||||
Assert.AreEqual(defaultCompressionBefore, defaultAfter.Settings["compressionFormat"],
|
||||
"Default platform settings must be unchanged by a platform override");
|
||||
|
||||
// Clear the override.
|
||||
AssetImportCommands.SetImportSettings(
|
||||
PathRef(path),
|
||||
new JObject { ["overridden"] = false },
|
||||
platform: "Android");
|
||||
|
||||
var afterClear = AssetImportCommands.GetImportSettings(PathRef(path), platform: "Android");
|
||||
Assert.AreEqual(false, afterClear.PlatformOverride["overridden"], "Override should be cleared");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ model: read + default write
|
||||
|
||||
[Test]
|
||||
public void GetImportSettings_Model_ReturnsNullPlatformOverride()
|
||||
{
|
||||
var path = CreateModel();
|
||||
|
||||
var result = AssetImportCommands.GetImportSettings(PathRef(path));
|
||||
|
||||
Assert.AreEqual("ModelImporter", result.ImporterType);
|
||||
Assert.IsTrue(result.Settings.ContainsKey("meshCompression"));
|
||||
Assert.IsTrue(result.Settings.ContainsKey("animationType"));
|
||||
Assert.IsTrue(result.Settings.ContainsKey("materialImportMode"));
|
||||
Assert.IsNull(result.PlatformOverride, "ModelImporter has no per-platform overrides");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetImportSettings_ModelDefault_AppliesAndReadBack()
|
||||
{
|
||||
var path = CreateModel();
|
||||
|
||||
var set = AssetImportCommands.SetImportSettings(
|
||||
PathRef(path),
|
||||
new JObject { ["meshCompression"] = "High", ["importNormals"] = "Calculate" });
|
||||
|
||||
CollectionAssert.Contains(set.Applied, "meshCompression");
|
||||
CollectionAssert.Contains(set.Applied, "importNormals");
|
||||
|
||||
var read = AssetImportCommands.GetImportSettings(PathRef(path));
|
||||
Assert.AreEqual("High", read.Settings["meshCompression"]);
|
||||
Assert.AreEqual("Calculate", read.Settings["importNormals"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetImportSettings_ModelWithPlatform_ReturnsNoPlatformOverrides_AndWritesNothing()
|
||||
{
|
||||
var path = CreateModel();
|
||||
var before = AssetImportCommands.GetImportSettings(PathRef(path));
|
||||
var meshBefore = before.Settings["meshCompression"];
|
||||
|
||||
var ex = Assert.Throws<System.ArgumentException>(() =>
|
||||
AssetImportCommands.SetImportSettings(
|
||||
PathRef(path),
|
||||
new JObject { ["meshCompression"] = "High" },
|
||||
platform: "iOS"));
|
||||
StringAssert.Contains("no_platform_overrides", ex.Message);
|
||||
|
||||
var after = AssetImportCommands.GetImportSettings(PathRef(path));
|
||||
Assert.AreEqual(meshBefore, after.Settings["meshCompression"], "A rejected platform write must change nothing");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ dry_run
|
||||
|
||||
[Test]
|
||||
public void SetImportSettings_DryRun_ReportsButDoesNotChange()
|
||||
{
|
||||
var path = CreateTexture();
|
||||
var before = AssetImportCommands.GetImportSettings(PathRef(path));
|
||||
var readableBefore = (bool)before.Settings["isReadable"];
|
||||
|
||||
var result = AssetImportCommands.SetImportSettings(
|
||||
PathRef(path),
|
||||
new JObject { ["isReadable"] = !readableBefore },
|
||||
dryRun: true);
|
||||
|
||||
CollectionAssert.Contains(result.Applied, "isReadable");
|
||||
|
||||
var after = AssetImportCommands.GetImportSettings(PathRef(path));
|
||||
Assert.AreEqual(readableBefore, (bool)after.Settings["isReadable"], "dry_run must not change settings");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ unknown key handling
|
||||
|
||||
[Test]
|
||||
public void SetImportSettings_UnknownKey_GoesToUnknown()
|
||||
{
|
||||
var path = CreateTexture();
|
||||
|
||||
var result = AssetImportCommands.SetImportSettings(
|
||||
PathRef(path),
|
||||
new JObject { ["isReadable"] = true, ["definitelyNotAMember"] = 42 });
|
||||
|
||||
CollectionAssert.Contains(result.Applied, "isReadable");
|
||||
CollectionAssert.Contains(result.Unknown, "definitelyNotAMember");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetImportSettings_AllUnknown_Throws()
|
||||
{
|
||||
var path = CreateTexture();
|
||||
|
||||
Assert.Throws<System.ArgumentException>(() =>
|
||||
AssetImportCommands.SetImportSettings(
|
||||
PathRef(path),
|
||||
new JObject { ["definitelyNotAMember"] = 42 }));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ platform validation
|
||||
|
||||
[Test]
|
||||
public void SetImportSettings_UnknownPlatform_Throws()
|
||||
{
|
||||
var path = CreateTexture();
|
||||
|
||||
var ex = Assert.Throws<System.ArgumentException>(() =>
|
||||
AssetImportCommands.SetImportSettings(
|
||||
PathRef(path),
|
||||
new JObject { ["maxTextureSize"] = 512 },
|
||||
platform: "PlayStation"));
|
||||
StringAssert.Contains("unknown_platform", ex.Message);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetImportSettings_UnknownPlatform_Throws()
|
||||
{
|
||||
var path = CreateTexture();
|
||||
|
||||
Assert.Throws<System.ArgumentException>(() =>
|
||||
AssetImportCommands.GetImportSettings(PathRef(path), platform: "PlayStation"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ sandbox guard
|
||||
|
||||
[Test]
|
||||
public void GetImportSettings_OutsideAuthoringRoot_Throws()
|
||||
{
|
||||
var path = CreateTexture();
|
||||
// Confine the authoring root to a sub-folder that does NOT contain the asset, so the asset's
|
||||
// resolved path falls outside the sandbox.
|
||||
ProjectPaths.AuthoringRoot = Root + "/Nested";
|
||||
|
||||
Assert.Throws<System.ArgumentException>(() =>
|
||||
AssetImportCommands.GetImportSettings(PathRef(path)));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ via client (wire) smoke test
|
||||
|
||||
[Test]
|
||||
public void GetImportSettings_ViaClient_Succeeds()
|
||||
{
|
||||
var path = CreateTexture();
|
||||
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("get_import_settings", new
|
||||
{
|
||||
asset = new { path },
|
||||
platform = "Android"
|
||||
});
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, $"get_import_settings should succeed: {response.Error}");
|
||||
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
|
||||
Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+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>
|
||||
class MaterialAssetCommandsTests
|
||||
{
|
||||
private const string Root = "Assets/__CLI221_222Test";
|
||||
|
||||
// A shader that is guaranteed to exist on whatever pipeline this project runs (built-in or URP).
|
||||
private string m_KnownShader;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
ProjectPaths.ResetAuthoringRoot();
|
||||
m_KnownShader = PickKnownShader();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
ProjectPaths.ResetAuthoringRoot();
|
||||
if (AssetDatabase.IsValidFolder(Root))
|
||||
{
|
||||
AssetDatabase.DeleteAsset(Root);
|
||||
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Pick a shader name that resolves on this project's active pipeline.</summary>
|
||||
private static string PickKnownShader()
|
||||
{
|
||||
if (Shader.Find("Standard") != null)
|
||||
return "Standard";
|
||||
if (Shader.Find("Universal Render Pipeline/Lit") != null)
|
||||
return "Universal Render Pipeline/Lit";
|
||||
// Sprites/Default ships with every pipeline as a hard fallback.
|
||||
return "Sprites/Default";
|
||||
}
|
||||
|
||||
#region Direct
|
||||
|
||||
[Test]
|
||||
public void CreateAsset_Material_WithShader_CreatesValidMaterial()
|
||||
{
|
||||
var path = Root + "/Mats/Bar.mat";
|
||||
var result = AssetCommands.CreateAsset(path, "Material", shader: m_KnownShader);
|
||||
|
||||
Assert.AreEqual(path, result.AssetPath);
|
||||
|
||||
var loaded = AssetDatabase.LoadAssetAtPath<Material>(path);
|
||||
Assert.IsNotNull(loaded, "Created .mat should load as a Material");
|
||||
Assert.IsNotNull(loaded.shader, "Material's shader must not be null");
|
||||
Assert.AreEqual(Shader.Find(m_KnownShader), loaded.shader, "Material should use the requested shader");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateAsset_Material_DefaultShader_IsNonNull()
|
||||
{
|
||||
// No explicit shader: should fall back to the active pipeline's lit shader (or Standard).
|
||||
var path = Root + "/Mats/Default.mat";
|
||||
AssetCommands.CreateAsset(path, "Material");
|
||||
|
||||
var loaded = AssetDatabase.LoadAssetAtPath<Material>(path);
|
||||
Assert.IsNotNull(loaded, "Created .mat should load as a Material");
|
||||
Assert.IsNotNull(loaded.shader, "Default-shader Material must have a non-null shader");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateAsset_Material_InvalidShader_ThrowsClearError()
|
||||
{
|
||||
var path = Root + "/Mats/Broken.mat";
|
||||
|
||||
var ex = Assert.Throws<System.ArgumentException>(
|
||||
() => AssetCommands.CreateAsset(path, "Material", shader: "No/Such/Shader__CLI221"),
|
||||
"An unknown shader name should fail with a clear ArgumentException");
|
||||
StringAssert.Contains("not found", ex.Message);
|
||||
|
||||
Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(path), "No asset should be written on a shader failure");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateAsset_Material_DryRun_InvalidShader_ThrowsAndWritesNothing()
|
||||
{
|
||||
// dry_run is documented as "validate inputs", so an unknown shader must fail fast even in
|
||||
// dry_run (rather than reporting a would-be success) and still write nothing.
|
||||
var path = Root + "/Mats/DryRunBroken.mat";
|
||||
|
||||
var ex = Assert.Throws<System.ArgumentException>(
|
||||
() => AssetCommands.CreateAsset(path, "Material", shader: "No/Such/Shader__CLI221", dryRun: true),
|
||||
"dry_run should resolve/validate the shader and fail on an unknown one");
|
||||
StringAssert.Contains("not found", ex.Message);
|
||||
|
||||
Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(path), "dry_run must never write an asset");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateAsset_Material_DryRun_ValidShader_WritesNothing()
|
||||
{
|
||||
// A valid dry_run resolves the shader (no throw) but writes no asset.
|
||||
var path = Root + "/Mats/DryRunOk.mat";
|
||||
var result = AssetCommands.CreateAsset(path, "Material", shader: m_KnownShader, dryRun: true);
|
||||
|
||||
Assert.AreEqual(path, result.AssetPath);
|
||||
Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(path), "dry_run must never write an asset");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateAsset_Material_AssignableToRendererSlot()
|
||||
{
|
||||
var path = Root + "/Mats/Assignable.mat";
|
||||
AssetCommands.CreateAsset(path, "Material", shader: m_KnownShader);
|
||||
var mat = AssetDatabase.LoadAssetAtPath<Material>(path);
|
||||
|
||||
var go = new GameObject("__CLI221_Renderer");
|
||||
try
|
||||
{
|
||||
var renderer = go.AddComponent<MeshRenderer>();
|
||||
renderer.sharedMaterial = mat;
|
||||
|
||||
Assert.AreEqual(mat, renderer.sharedMaterial, "Created Material should be assignable to a Renderer slot");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(go);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ViaClient
|
||||
|
||||
[Test]
|
||||
public void CreateAsset_Material_ViaClient_Succeeds()
|
||||
{
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var path = Root + "/ViaClient/Wall.mat";
|
||||
var response = server.Execute("create_asset", new { path, type = "Material", shader = m_KnownShader });
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, $"create_asset (Material) should succeed: {response.Error}");
|
||||
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
|
||||
Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field");
|
||||
|
||||
var loaded = AssetDatabase.LoadAssetAtPath<Material>(path);
|
||||
Assert.IsNotNull(loaded, "Material should exist on disk");
|
||||
Assert.IsNotNull(loaded.shader, "Material's shader must not be null");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateAsset_Material_ViaClient_InvalidShader_Fails()
|
||||
{
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
// The server logs the failure as a Unity [Error]; expect it so the test does not fail on
|
||||
// the unexpected log. Matches the message thrown by ResolveShader ("Shader '...' not found.").
|
||||
LogAssert.Expect(LogType.Error, new Regex("not found"));
|
||||
|
||||
var path = Root + "/ViaClient/Broken.mat";
|
||||
var response = server.Execute("create_asset", new { path, type = "Material", shader = "No/Such/Shader__CLI221" });
|
||||
|
||||
Assert.IsFalse(response.IsSuccess, "An unknown shader should fail over the wire");
|
||||
Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(path), "No asset should be written on a shader failure");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7d514449899cc0f439d287ab64433a22
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Editor.Commands.Assets;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
#if !UNITY_6000_0_OR_NEWER
|
||||
using PhysicsMaterial = UnityEngine.PhysicMaterial;
|
||||
#endif
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor.Assets
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for create_asset's PhysicsMaterial support (CLI-222), exercised directly and via the
|
||||
/// isolated <see cref="PipelineTestServer"/>. Verifies that BOTH the Unity 6 type name
|
||||
/// "PhysicsMaterial" and the legacy "PhysicMaterial" resolve to <see cref="PhysicsMaterial"/>, that
|
||||
/// the created asset carries the documented defaults (bounciness 0, frictions 0.6), is discoverable
|
||||
/// via find_assets, and is assignable to a Collider's material slot.
|
||||
///
|
||||
/// NOTE: Unity 6 renamed the class to PhysicsMaterial but the on-disk asset extension is still
|
||||
/// ".physicMaterial" (AssetDatabase.CreateAsset rejects ".physicsMaterial"). create_asset normalizes
|
||||
/// a ".physicsMaterial" request to ".physicMaterial", so the created path is asserted via the
|
||||
/// command result rather than the requested path.
|
||||
/// </summary>
|
||||
class PhysicsMaterialAssetCommandsTests
|
||||
{
|
||||
private const string Root = "Assets/__CLI221_222Test";
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
ProjectPaths.ResetAuthoringRoot();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
ProjectPaths.ResetAuthoringRoot();
|
||||
if (AssetDatabase.IsValidFolder(Root))
|
||||
{
|
||||
AssetDatabase.DeleteAsset(Root);
|
||||
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AssertDefaults(PhysicsMaterial pm)
|
||||
{
|
||||
Assert.IsNotNull(pm, "Created physics material should load as a PhysicsMaterial");
|
||||
Assert.AreEqual(0f, pm.bounciness, 1e-4f, "bounciness default should be 0");
|
||||
Assert.AreEqual(0.6f, pm.dynamicFriction, 1e-4f, "dynamicFriction default should be 0.6");
|
||||
Assert.AreEqual(0.6f, pm.staticFriction, 1e-4f, "staticFriction default should be 0.6");
|
||||
}
|
||||
|
||||
#region Direct
|
||||
|
||||
[Test]
|
||||
public void CreateAsset_PhysicsMaterial_CreatesWithDefaults()
|
||||
{
|
||||
var result = AssetCommands.CreateAsset(Root + "/Phys/Ball.physicMaterial", "PhysicsMaterial");
|
||||
|
||||
StringAssert.EndsWith(".physicMaterial", result.AssetPath);
|
||||
AssertDefaults(AssetDatabase.LoadAssetAtPath<PhysicsMaterial>(result.AssetPath));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateAsset_PhysicsMaterial_NormalizesModernExtension()
|
||||
{
|
||||
// CLI-222: an agent may pass the modern ".physicsMaterial" extension; it must succeed,
|
||||
// normalized to the ".physicMaterial" file Unity actually writes.
|
||||
var result = AssetCommands.CreateAsset(Root + "/Phys/Modern.physicsMaterial", "PhysicsMaterial");
|
||||
|
||||
StringAssert.EndsWith(".physicMaterial", result.AssetPath);
|
||||
AssertDefaults(AssetDatabase.LoadAssetAtPath<PhysicsMaterial>(result.AssetPath));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateAsset_PhysicsMaterial_LegacyTypeName_ResolvesToSameType()
|
||||
{
|
||||
// CLI-222: the legacy "PhysicMaterial" spelling must resolve to UnityEngine.PhysicsMaterial.
|
||||
var result = AssetCommands.CreateAsset(Root + "/Phys/Legacy.physicMaterial", "PhysicMaterial");
|
||||
|
||||
var loaded = AssetDatabase.LoadMainAssetAtPath(result.AssetPath);
|
||||
Assert.IsInstanceOf<PhysicsMaterial>(loaded, "Legacy 'PhysicMaterial' type name should resolve to PhysicsMaterial");
|
||||
AssertDefaults(loaded as PhysicsMaterial);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateAsset_PhysicsMaterial_FoundByFindAssets()
|
||||
{
|
||||
var result = AssetCommands.CreateAsset(Root + "/Phys/Findable.physicMaterial", "PhysicsMaterial");
|
||||
|
||||
var found = AssetCommands.FindAssets(type: "PhysicsMaterial", searchIn: Root);
|
||||
|
||||
Assert.GreaterOrEqual(found.Count, 1, "Should find the created PhysicsMaterial by type");
|
||||
CollectionAssert.Contains(
|
||||
System.Linq.Enumerable.Select(found.Assets, a => a.AssetPath),
|
||||
result.AssetPath);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateAsset_PhysicsMaterial_AssignableToColliderSlot()
|
||||
{
|
||||
var result = AssetCommands.CreateAsset(Root + "/Phys/Assignable.physicMaterial", "PhysicsMaterial");
|
||||
var pm = AssetDatabase.LoadAssetAtPath<PhysicsMaterial>(result.AssetPath);
|
||||
|
||||
var go = new GameObject("__CLI222_Collider");
|
||||
try
|
||||
{
|
||||
var collider = go.AddComponent<BoxCollider>();
|
||||
collider.sharedMaterial = pm;
|
||||
|
||||
Assert.AreEqual(pm, collider.sharedMaterial, "Created PhysicsMaterial should be assignable to a Collider slot");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(go);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ViaClient
|
||||
|
||||
[Test]
|
||||
public void CreateAsset_PhysicsMaterial_ViaClient_Succeeds()
|
||||
{
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("create_asset",
|
||||
new { path = Root + "/ViaClient/Ball.physicMaterial", type = "PhysicsMaterial" });
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, $"create_asset (PhysicsMaterial) should succeed: {response.Error}");
|
||||
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
|
||||
Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field");
|
||||
|
||||
var created = (string)response.JsonResponse["result"]["assetPath"];
|
||||
AssertDefaults(AssetDatabase.LoadAssetAtPath<PhysicsMaterial>(created));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateAsset_PhysicsMaterial_ViaClient_LegacyName_Succeeds()
|
||||
{
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("create_asset",
|
||||
new { path = Root + "/ViaClient/Legacy.physicMaterial", type = "PhysicMaterial" });
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, $"create_asset (legacy PhysicMaterial) should succeed: {response.Error}");
|
||||
|
||||
var created = (string)response.JsonResponse["result"]["assetPath"];
|
||||
Assert.IsInstanceOf<PhysicsMaterial>(AssetDatabase.LoadMainAssetAtPath(created),
|
||||
"Legacy 'PhysicMaterial' should resolve to PhysicsMaterial over the wire");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: af05410e747b85c47ab38e0bf2db7fd8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,108 @@
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Editor.Commands.Assets;
|
||||
using UnityEditor;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor.Assets
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for read_text_file / write_text_file (CLI-191), direct and via the isolated
|
||||
/// <see cref="PipelineTestServer"/>. Covers round-tripping content, the overwrite-confirm guard, and
|
||||
/// the sandbox guard (out-of-project writes rejected).
|
||||
/// </summary>
|
||||
class TextFileCommandsTests
|
||||
{
|
||||
private const string Root = "Assets/__CLI191TextTest";
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
ProjectPaths.ResetAuthoringRoot();
|
||||
if (AssetDatabase.IsValidFolder(Root))
|
||||
{
|
||||
AssetDatabase.DeleteAsset(Root);
|
||||
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||
}
|
||||
}
|
||||
|
||||
#region Direct
|
||||
|
||||
[Test]
|
||||
public void WriteThenRead_RoundTrips()
|
||||
{
|
||||
var path = Root + "/Notes/hello.txt";
|
||||
const string content = "hello cli-191";
|
||||
|
||||
var write = TextFileCommands.WriteTextFile(path, content);
|
||||
Assert.AreEqual(path, write.AssetPath);
|
||||
|
||||
var read = TextFileCommands.ReadTextFile(path);
|
||||
Assert.AreEqual(content, read.Contents);
|
||||
Assert.AreEqual(System.Text.Encoding.UTF8.GetByteCount(content), read.Bytes);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WriteTextFile_OverwriteWithoutConfirm_Throws()
|
||||
{
|
||||
var path = Root + "/Notes/dup.txt";
|
||||
TextFileCommands.WriteTextFile(path, "first");
|
||||
|
||||
Assert.Throws<System.ArgumentException>(
|
||||
() => TextFileCommands.WriteTextFile(path, "second"),
|
||||
"Overwriting an existing file without confirm should be rejected");
|
||||
|
||||
var ok = TextFileCommands.WriteTextFile(path, "second", confirm: true);
|
||||
Assert.AreEqual(path, ok.AssetPath);
|
||||
Assert.AreEqual("second", TextFileCommands.ReadTextFile(path).Contents);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WriteTextFile_OutOfProject_Throws()
|
||||
{
|
||||
Assert.Throws<System.ArgumentException>(() => TextFileCommands.WriteTextFile("../escape.txt", "x"));
|
||||
Assert.Throws<System.ArgumentException>(() => TextFileCommands.WriteTextFile("Assets/../escape.txt", "x"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReadTextFile_Missing_Throws()
|
||||
{
|
||||
Assert.Throws<System.ArgumentException>(() => TextFileCommands.ReadTextFile(Root + "/does-not-exist.txt"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ViaClient
|
||||
|
||||
[Test]
|
||||
public void WriteTextFile_ViaClient_Succeeds()
|
||||
{
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var path = Root + "/ViaClient/wire.txt";
|
||||
var response = server.Execute("write_text_file", new { path, contents = "over the wire" });
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, $"write_text_file should succeed: {response.Error}");
|
||||
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
|
||||
Assert.AreEqual("over the wire", TextFileCommands.ReadTextFile(path).Contents);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReadTextFile_ViaClient_Succeeds()
|
||||
{
|
||||
var path = Root + "/ViaClient/readable.txt";
|
||||
TextFileCommands.WriteTextFile(path, "readable content");
|
||||
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("read_text_file", new { path });
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, $"read_text_file should succeed: {response.Error}");
|
||||
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
|
||||
Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4ede31badcf640f6acc3c5baf0fc8ef3
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 497149ab4929cd34483ff611ce25534a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,85 @@
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Editor.Commands.Assets;
|
||||
using UnityEditor;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor.Authoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the create_folder reference command (CLI-190), exercised directly and via
|
||||
/// PipelineClient. Verifies the result envelope, recursive creation, Assets-relative paths,
|
||||
/// and the traversal guard.
|
||||
/// </summary>
|
||||
class FolderCommandsTests
|
||||
{
|
||||
private const string Root = "Assets/__CLI190Test";
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
// Start from the default authoring root so a leftover EditorPref (from another test or an
|
||||
// interrupted run) can't shift where bare paths resolve mid-suite.
|
||||
ProjectPaths.ResetAuthoringRoot();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
ProjectPaths.ResetAuthoringRoot();
|
||||
if (AssetDatabase.IsValidFolder(Root))
|
||||
{
|
||||
AssetDatabase.DeleteAsset(Root);
|
||||
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||
}
|
||||
}
|
||||
|
||||
#region Direct
|
||||
|
||||
[Test]
|
||||
public void CreateFolder_ExplicitAssetsPath_CreatesFoldersAndReturnsIdentity()
|
||||
{
|
||||
var result = FolderCommands.CreateFolder(Root + "/Sub/Leaf");
|
||||
|
||||
Assert.IsTrue(AssetDatabase.IsValidFolder(Root + "/Sub/Leaf"), "Nested folders should exist");
|
||||
Assert.AreEqual(Root + "/Sub/Leaf", result.AssetPath);
|
||||
Assert.IsNotEmpty(result.Guid, "Created folder should have a GUID");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateFolder_BarePath_ResolvesUnderAssets()
|
||||
{
|
||||
// No "Assets/" prefix — should resolve under the default authoring root (Assets/).
|
||||
var result = FolderCommands.CreateFolder("__CLI190Test/Bare");
|
||||
|
||||
Assert.AreEqual("Assets/__CLI190Test/Bare", result.AssetPath);
|
||||
Assert.IsTrue(AssetDatabase.IsValidFolder("Assets/__CLI190Test/Bare"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateFolder_Traversal_Throws()
|
||||
{
|
||||
Assert.Throws<System.ArgumentException>(() => FolderCommands.CreateFolder("../Outside"));
|
||||
Assert.Throws<System.ArgumentException>(() => FolderCommands.CreateFolder("Assets/../Outside"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ViaClient
|
||||
|
||||
[Test]
|
||||
public void CreateFolder_ViaClient_Succeeds()
|
||||
{
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("create_folder", new { path = Root + "/ViaClient" });
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, $"create_folder should succeed: {response.Error}");
|
||||
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
|
||||
Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field");
|
||||
Assert.IsTrue(AssetDatabase.IsValidFolder(Root + "/ViaClient"), "Folder should be created");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
+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>
|
||||
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:
|
||||
@@ -0,0 +1,145 @@
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using Object = UnityEngine.Object;
|
||||
using Unity.Pipeline;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor.Authoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the object-reference resolver foundation (CLI-190): each handle form resolves back
|
||||
/// to the same object, and Describe produces a canonical identity.
|
||||
/// </summary>
|
||||
class ObjectResolverTests
|
||||
{
|
||||
private const string AssetFolder = "Assets/AUTHAPI9_Res";
|
||||
|
||||
private GameObject m_SceneObject;
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
if (m_SceneObject != null)
|
||||
Object.DestroyImmediate(m_SceneObject);
|
||||
m_SceneObject = null;
|
||||
|
||||
if (AssetDatabase.IsValidFolder(AssetFolder))
|
||||
{
|
||||
AssetDatabase.DeleteAsset(AssetFolder);
|
||||
AssetDatabase.Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateMaterialAsset(string name)
|
||||
{
|
||||
if (!AssetDatabase.IsValidFolder(AssetFolder))
|
||||
AssetDatabase.CreateFolder("Assets", "AUTHAPI9_Res");
|
||||
|
||||
var shader = Shader.Find("Standard")
|
||||
?? Shader.Find("Universal Render Pipeline/Lit")
|
||||
?? Shader.Find("Sprites/Default");
|
||||
var mat = new Material(shader);
|
||||
var path = $"{AssetFolder}/{name}.mat";
|
||||
AssetDatabase.CreateAsset(mat, path);
|
||||
AssetDatabase.SaveAssets();
|
||||
return path;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Describe_SceneObject_ProducesInstanceIdAndHierarchyPath()
|
||||
{
|
||||
m_SceneObject = new GameObject("CLI190_Root");
|
||||
var child = new GameObject("Child");
|
||||
child.transform.SetParent(m_SceneObject.transform);
|
||||
|
||||
var info = ObjectResolver.Describe(child);
|
||||
|
||||
Assert.IsNotNull(info);
|
||||
Assert.AreEqual(PipelineUtils.GetObjectId(child), info.InstanceId);
|
||||
Assert.AreEqual("/CLI190_Root/Child", info.HierarchyPath);
|
||||
Assert.AreEqual("GameObject", info.Type);
|
||||
Assert.IsNull(info.AssetPath, "A scene object should not report an asset path");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolve_ByInstanceId_ReturnsSameObject()
|
||||
{
|
||||
m_SceneObject = new GameObject("CLI190_ById");
|
||||
var handle = new ObjectRef { InstanceId = PipelineUtils.GetObjectId(m_SceneObject) };
|
||||
|
||||
Assert.IsTrue(ObjectResolver.TryResolve(handle, out var obj, out var error), error);
|
||||
Assert.AreSame(m_SceneObject, obj);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolve_ByHierarchyPath_ReturnsSameObject()
|
||||
{
|
||||
m_SceneObject = new GameObject("CLI190_ByPath");
|
||||
var handle = new ObjectRef { HierarchyPath = "/CLI190_ByPath" };
|
||||
|
||||
Assert.IsTrue(ObjectResolver.TryResolve(handle, out var obj, out var error), error);
|
||||
Assert.AreSame(m_SceneObject, obj);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolve_EmptyHandle_FailsWithError()
|
||||
{
|
||||
Assert.IsFalse(ObjectResolver.TryResolve(new ObjectRef(), out _, out var error));
|
||||
Assert.IsNotEmpty(error);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolve_UnknownGuid_FailsWithError()
|
||||
{
|
||||
var handle = new ObjectRef { Guid = "00000000000000000000000000000000" };
|
||||
Assert.IsFalse(ObjectResolver.TryResolve(handle, out _, out var error));
|
||||
Assert.IsNotEmpty(error);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolve_ByRelativeAssetPath_NormalizesUnderAuthoringRoot()
|
||||
{
|
||||
// A path without the "Assets/" prefix (AUTHAPI-9) resolves under the authoring root.
|
||||
var full = CreateMaterialAsset("Floor");
|
||||
var expected = AssetDatabase.LoadMainAssetAtPath(full);
|
||||
var handle = new ObjectRef { Path = "AUTHAPI9_Res/Floor.mat" };
|
||||
|
||||
Assert.IsTrue(ObjectResolver.TryResolve(handle, out var obj, out var error), error);
|
||||
Assert.AreSame(expected, obj);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolve_ExplicitAssetsPath_StillResolves()
|
||||
{
|
||||
var full = CreateMaterialAsset("Wall");
|
||||
var handle = new ObjectRef { Path = full };
|
||||
|
||||
Assert.IsTrue(ObjectResolver.TryResolve(handle, out var obj, out var error), error);
|
||||
Assert.AreSame(AssetDatabase.LoadMainAssetAtPath(full), obj);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolve_DottedGameObjectName_FallsBackToHierarchy()
|
||||
{
|
||||
// A dotted name (e.g. "Cube.001") is routed to Path by the string converter but is really a
|
||||
// scene object; the Path branch falls back to a hierarchy lookup.
|
||||
m_SceneObject = new GameObject("Cube.001");
|
||||
var handle = new ObjectRef { Path = "Cube.001" };
|
||||
|
||||
Assert.IsTrue(ObjectResolver.TryResolve(handle, out var obj, out var error), error);
|
||||
Assert.AreSame(m_SceneObject, obj);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolve_UnresolvableRelativePath_ErrorListsEveryStrategy()
|
||||
{
|
||||
var handle = new ObjectRef { Path = "AUTHAPI9_Res/Missing.mat" };
|
||||
|
||||
Assert.IsFalse(ObjectResolver.TryResolve(handle, out _, out var error));
|
||||
StringAssert.Contains("no asset at", error);
|
||||
StringAssert.Contains("no GameObject at hierarchy path", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e864b00059a57364d902089021b33742
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,84 @@
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor.Authoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for ProjectPaths (CLI-190): Assets-relative resolution, the traversal/out-of-root
|
||||
/// guards, and the configurable authoring root.
|
||||
/// </summary>
|
||||
class ProjectPathsTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
// Reset to the default root before each test so a leftover EditorPref can't leak in.
|
||||
ProjectPaths.ResetAuthoringRoot();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
ProjectPaths.ResetAuthoringRoot();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DefaultRoot_IsAssets()
|
||||
{
|
||||
ProjectPaths.ResetAuthoringRoot();
|
||||
Assert.AreEqual("Assets", ProjectPaths.AuthoringRoot);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolve_BarePath_PrependsAssets()
|
||||
{
|
||||
var resolved = ProjectPaths.Resolve("Gameplay/Enemies", out var error);
|
||||
Assert.IsNull(error, error);
|
||||
Assert.AreEqual("Assets/Gameplay/Enemies", resolved);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolve_ExplicitAssetsPath_Unchanged()
|
||||
{
|
||||
var resolved = ProjectPaths.Resolve("Assets/Gameplay", out var error);
|
||||
Assert.IsNull(error, error);
|
||||
Assert.AreEqual("Assets/Gameplay", resolved);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolve_Traversal_Fails()
|
||||
{
|
||||
Assert.IsNull(ProjectPaths.Resolve("../Outside", out var error));
|
||||
Assert.IsNotEmpty(error);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolve_EmptyPath_Fails()
|
||||
{
|
||||
Assert.IsNull(ProjectPaths.Resolve("", out var error));
|
||||
Assert.IsNotEmpty(error);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ConfigurableRoot_ConfinesBareAndRejectsOutside()
|
||||
{
|
||||
ProjectPaths.AuthoringRoot = "Assets/__CLI190Root";
|
||||
|
||||
// Bare path resolves under the configured root.
|
||||
var inside = ProjectPaths.Resolve("Foo/Bar", out var insideError);
|
||||
Assert.IsNull(insideError, insideError);
|
||||
Assert.AreEqual("Assets/__CLI190Root/Foo/Bar", inside);
|
||||
|
||||
// Explicit path outside the configured root is rejected (confinement).
|
||||
Assert.IsNull(ProjectPaths.Resolve("Assets/Other", out var outsideError));
|
||||
Assert.IsNotEmpty(outsideError);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetAuthoringRoot_OutsideAssets_Throws()
|
||||
{
|
||||
Assert.Throws<System.ArgumentException>(() => ProjectPaths.AuthoringRoot = "Outside");
|
||||
Assert.Throws<System.ArgumentException>(() => ProjectPaths.AuthoringRoot = "Assets/../Escape");
|
||||
}
|
||||
}
|
||||
}
|
||||
+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,114 @@
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Editor.Commands;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for auto-tick's SessionState persistence across (simulated) domain reloads.
|
||||
/// The live editor's own server may already have auto-tick enabled, so SetUp/TearDown save and
|
||||
/// restore whatever state was live before each test instead of assuming it starts off.
|
||||
/// </summary>
|
||||
class AutoTickCommandTests
|
||||
{
|
||||
private bool m_PrevEnabled;
|
||||
private long m_PrevIntervalMs;
|
||||
private bool m_HadPersisted;
|
||||
private bool m_PrevPersistedEnabled;
|
||||
private long m_PrevPersistedIntervalMs;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
m_PrevEnabled = AutoTickCommand.IsEnabled;
|
||||
m_PrevIntervalMs = AutoTickCommand.CurrentIntervalMs;
|
||||
m_HadPersisted = AutoTickCommand.TryGetPersistedSessionForTests(out m_PrevPersistedEnabled, out m_PrevPersistedIntervalMs);
|
||||
AutoTickCommand.EraseSessionForTests();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
// persist:false so this only restores the live in-memory state, not SessionState -
|
||||
// the persisted baseline (or lack thereof) is restored/erased separately below.
|
||||
AutoTickCommand.SetAutoTick(m_PrevEnabled, (int)m_PrevIntervalMs, persist: false);
|
||||
|
||||
if (m_HadPersisted)
|
||||
AutoTickCommand.SetPersistedSessionForTests(m_PrevPersistedEnabled, m_PrevPersistedIntervalMs);
|
||||
else
|
||||
AutoTickCommand.EraseSessionForTests();
|
||||
}
|
||||
|
||||
[TestCase(true, 5)]
|
||||
[TestCase(true, 250)]
|
||||
[TestCase(false, 40)]
|
||||
public void RestoreFromSession_AfterSimulatedReload_RestoresExactPriorState(bool enable, int intervalMs)
|
||||
{
|
||||
AutoTickCommand.SetAutoTick(enable, intervalMs);
|
||||
|
||||
// A domain reload clears the statics (and the update-loop subscription with them) but
|
||||
// leaves the SessionState copy this call just wrote intact.
|
||||
AutoTickCommand.ResetForTests();
|
||||
|
||||
// Pass the opposite of the persisted value as the default, so a passing assertion proves
|
||||
// the persisted state won, not the default falling through by coincidence.
|
||||
AutoTickCommand.RestoreFromSession(defaultEnabled: !enable);
|
||||
|
||||
Assert.AreEqual(enable, AutoTickCommand.IsEnabled,
|
||||
"Restore must bring back the exact enabled state set via SetAutoTick, not the startup default.");
|
||||
if (enable)
|
||||
{
|
||||
Assert.AreEqual(intervalMs, AutoTickCommand.CurrentIntervalMs,
|
||||
"Restore must bring back the exact interval set via SetAutoTick, not the default interval.");
|
||||
}
|
||||
}
|
||||
|
||||
[TestCase(true)]
|
||||
[TestCase(false)]
|
||||
public void RestoreFromSession_NoPriorExplicitCallThisSession_AppliesDefault(bool defaultEnabled)
|
||||
{
|
||||
// SetUp already erased SessionState and this simulates a reload on top of that, so there
|
||||
// is no persisted choice for RestoreFromSession to find.
|
||||
AutoTickCommand.ResetForTests();
|
||||
|
||||
AutoTickCommand.RestoreFromSession(defaultEnabled);
|
||||
|
||||
Assert.AreEqual(defaultEnabled, AutoTickCommand.IsEnabled);
|
||||
if (defaultEnabled)
|
||||
{
|
||||
Assert.AreEqual(AutoTickCommand.DefaultIntervalMs, AutoTickCommand.CurrentIntervalMs,
|
||||
"With no explicit prior choice, restoring 'enabled' must use the default interval.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetAutoTick_WithPersistFalse_RevertsToPriorPersistedBaselineAfterSimulatedReload()
|
||||
{
|
||||
// Establish a persisted baseline, as if the user had configured a normal auto-tick rate.
|
||||
AutoTickCommand.SetAutoTick(true, 200);
|
||||
|
||||
// A temporary, expensive override (interval_ms=0 pegs a CPU core) that must not overwrite
|
||||
// the baseline above.
|
||||
AutoTickCommand.SetAutoTick(true, 0, persist: false);
|
||||
Assert.AreEqual(0, AutoTickCommand.CurrentIntervalMs, "The temporary override should still apply immediately.");
|
||||
|
||||
AutoTickCommand.ResetForTests();
|
||||
AutoTickCommand.RestoreFromSession(defaultEnabled: false);
|
||||
|
||||
Assert.AreEqual(200, AutoTickCommand.CurrentIntervalMs,
|
||||
"persist:false must not clobber the previously persisted baseline.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetAutoTick_WithPersistFalse_NoPriorBaseline_DoesNotSurviveSimulatedReload()
|
||||
{
|
||||
AutoTickCommand.SetAutoTick(true, 0, persist: false);
|
||||
Assert.IsTrue(AutoTickCommand.IsEnabled);
|
||||
|
||||
AutoTickCommand.ResetForTests();
|
||||
AutoTickCommand.RestoreFromSession(defaultEnabled: false);
|
||||
|
||||
Assert.IsFalse(AutoTickCommand.IsEnabled,
|
||||
"With no persisted baseline, a session-only (persist:false) call must not survive a reload.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 33094abdda6430b4d883852f20d4132f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5e5fd89f0ad042538cfef1bd87763a27
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+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>
|
||||
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>
|
||||
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>
|
||||
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:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1f0ae5fba45d4e60a0bfc82f77f35069
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,958 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using NUnit.Framework;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Unity.Pipeline;
|
||||
using Unity.Pipeline.Editor.Commands.Batch;
|
||||
using Unity.Pipeline.Editor.Commands.GameObjects;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor.Batch
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the transactional <c>batch</c> command (AUTHAPI-27), driven end-to-end through an
|
||||
/// isolated <see cref="PipelineTestServer"/> (the ViaClient pattern). Covers the acceptance
|
||||
/// criteria: cross-op references resolve, a mid-batch failure atomically rolls back every prior
|
||||
/// op, a successful batch collapses into a single Undo group, dry-run validates without mutating,
|
||||
/// a bad reference errors clearly, <c>on_error=continue</c> applies independent ops without
|
||||
/// reverting, and the 200-op cap is enforced.
|
||||
/// </summary>
|
||||
class BatchCommandTests
|
||||
{
|
||||
private readonly List<GameObject> m_Spawned = new List<GameObject>();
|
||||
|
||||
private GameObject Track(GameObject go)
|
||||
{
|
||||
if (go != null)
|
||||
m_Spawned.Add(go);
|
||||
return go;
|
||||
}
|
||||
|
||||
private static ObjectId? InstanceIdOf(JToken opResult) =>
|
||||
opResult?["result"]?["instanceId"]?.ToObject<ObjectId?>();
|
||||
|
||||
private GameObject ResolveGameObject(JToken opResult)
|
||||
{
|
||||
var id = InstanceIdOf(opResult);
|
||||
if (!id.HasValue)
|
||||
return null;
|
||||
return Track(PipelineUtils.IdToObject(id.Value) as GameObject);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
foreach (var go in m_Spawned)
|
||||
{
|
||||
if (go != null)
|
||||
Object.DestroyImmediate(go);
|
||||
}
|
||||
m_Spawned.Clear();
|
||||
|
||||
// Deliberately NO Undo.ClearAll() here: this suite runs inside the dogfood editor, and
|
||||
// wiping the global Undo stack would destroy the developer's own undo history. Per-test
|
||||
// state is cleaned up by destroying m_Spawned above; the batch Undo groups left on the
|
||||
// stack only reference those (now destroyed) test objects and are inert.
|
||||
}
|
||||
|
||||
private static JObject Op(string command, JObject parameters = null, string id = null)
|
||||
{
|
||||
var op = new JObject { ["command"] = command };
|
||||
if (id != null)
|
||||
op["id"] = id;
|
||||
op["params"] = parameters ?? new JObject();
|
||||
return op;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Batch_CrossOpReferences_ResolveAndApply()
|
||||
{
|
||||
// AC: create_gameobject -> add_component -> set_serialized_field, where later ops use
|
||||
// $0.instanceId / $1.instanceId, executes in one round-trip with references resolved.
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var operations = new JArray
|
||||
{
|
||||
Op("create_gameobject", new JObject { ["name"] = "BatchRef_Root" }),
|
||||
Op("add_component", new JObject { ["target"] = "$0.instanceId", ["type"] = "Rigidbody" }),
|
||||
Op("set_serialized_field", new JObject
|
||||
{
|
||||
["target"] = "$1.instanceId",
|
||||
["field"] = "m_Mass",
|
||||
["value"] = new JValue(2.5f)
|
||||
}),
|
||||
};
|
||||
|
||||
var response = server.Execute("batch", new JObject { ["operations"] = operations });
|
||||
Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}");
|
||||
|
||||
var batch = response.JsonResponse["result"];
|
||||
Assert.AreEqual(3, batch["applied"]?.ToObject<int>(), "all three ops should apply");
|
||||
Assert.IsFalse(batch["reverted"]?.ToObject<bool>() ?? true, "a successful batch must not revert");
|
||||
Assert.IsNotNull(batch["undoGroup"], "execution result should carry the batch Undo group");
|
||||
|
||||
var results = batch["results"] as JArray;
|
||||
Assert.AreEqual(3, results.Count);
|
||||
for (int i = 0; i < 3; i++)
|
||||
Assert.IsTrue(results[i]["success"]?.ToObject<bool>() ?? false,
|
||||
$"op {i} should succeed: {results[i]["error"]}");
|
||||
|
||||
// The reference chain wired a Rigidbody onto op 0's GameObject and set its mass.
|
||||
var root = ResolveGameObject(results[0]);
|
||||
Assert.IsNotNull(root, "op 0's GameObject should be resolvable from its returned instanceId");
|
||||
var rb = root.GetComponent<Rigidbody>();
|
||||
Assert.IsNotNull(rb, "$0.instanceId must resolve so add_component targets op 0's GameObject");
|
||||
Assert.AreEqual(2.5f, rb.mass, 0.0001f,
|
||||
"$1.instanceId must resolve so set_serialized_field targets the added Rigidbody");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Batch_Transactional_MidBatchFailure_RevertsAllPriorOps()
|
||||
{
|
||||
// AC: an op fails mid-batch -> every prior op is reverted; scene equals pre-batch state.
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var operations = new JArray
|
||||
{
|
||||
Op("create_gameobject", new JObject { ["name"] = "BatchRollback_A" }),
|
||||
Op("create_gameobject", new JObject { ["name"] = "BatchRollback_B" }),
|
||||
// Fails: unknown component type -> add_component throws before mutating.
|
||||
Op("add_component", new JObject { ["target"] = "$0.instanceId", ["type"] = "NoSuchTypeXYZ" }),
|
||||
};
|
||||
|
||||
// The failing sub-op logs an error through the shared command-dispatch path (batch
|
||||
// reuses it); the batch surfaces the failure structurally, but the log still fires.
|
||||
LogAssert.Expect(LogType.Error, new Regex("add_component' failed: Could not resolve component type 'NoSuchTypeXYZ'"));
|
||||
|
||||
var response = server.Execute("batch", new JObject { ["operations"] = operations });
|
||||
Assert.IsTrue(response.IsSuccess, $"batch itself should return HTTP success: {response.RawResponse}");
|
||||
|
||||
var batch = response.JsonResponse["result"];
|
||||
Assert.IsTrue(batch["reverted"]?.ToObject<bool>() ?? false, "a transactional failure must revert");
|
||||
Assert.AreEqual(2, batch["applied"]?.ToObject<int>(), "two ops ran before the failure");
|
||||
|
||||
var results = batch["results"] as JArray;
|
||||
Assert.IsTrue(results[0]["success"]?.ToObject<bool>() ?? false);
|
||||
Assert.IsTrue(results[1]["success"]?.ToObject<bool>() ?? false);
|
||||
Assert.IsFalse(results[2]["success"]?.ToObject<bool>() ?? true, "op 2 must fail");
|
||||
StringAssert.Contains("component type", results[2]["error"]?.ToString(),
|
||||
"the failing op's error should be reported");
|
||||
|
||||
// The two created objects must be gone (reverted), both by name and by their ids.
|
||||
Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchRollback_A").Count,
|
||||
"op 0's object must be reverted");
|
||||
Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchRollback_B").Count,
|
||||
"op 1's object must be reverted");
|
||||
|
||||
var id0 = InstanceIdOf(results[0]);
|
||||
var id1 = InstanceIdOf(results[1]);
|
||||
Assert.IsTrue(id0.HasValue && id1.HasValue, "reverted ops still report the ids they had created");
|
||||
Assert.IsNull(PipelineUtils.IdToObject(id0.Value), "op 0's object should no longer resolve");
|
||||
Assert.IsNull(PipelineUtils.IdToObject(id1.Value), "op 1's object should no longer resolve");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Batch_SuccessfulBatch_CollapsesIntoSingleUndoGroup()
|
||||
{
|
||||
// AC: a successful batch shows exactly one Undo group -> a single Undo reverts the whole batch.
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var operations = new JArray
|
||||
{
|
||||
Op("create_gameobject", new JObject { ["name"] = "BatchUndo_A" }),
|
||||
Op("create_gameobject", new JObject { ["name"] = "BatchUndo_B" }),
|
||||
};
|
||||
|
||||
var response = server.Execute("batch", new JObject { ["operations"] = operations });
|
||||
Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}");
|
||||
|
||||
// Track the created objects so TearDown cleans them up even if the Undo below misbehaves.
|
||||
var results = response.JsonResponse["result"]["results"] as JArray;
|
||||
ResolveGameObject(results[0]);
|
||||
ResolveGameObject(results[1]);
|
||||
|
||||
Assert.AreEqual(1, GameObjectCommands.FindGameObjects(name: "BatchUndo_A").Count);
|
||||
Assert.AreEqual(1, GameObjectCommands.FindGameObjects(name: "BatchUndo_B").Count);
|
||||
|
||||
// One Undo must revert BOTH creations, proving the whole batch collapsed into one group.
|
||||
Undo.PerformUndo();
|
||||
|
||||
Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchUndo_A").Count,
|
||||
"a single Undo should revert the first op (batch is one group)");
|
||||
Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchUndo_B").Count,
|
||||
"a single Undo should revert the second op too (batch is one group)");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Batch_DryRun_CatchesErrors_AndMutatesNothing()
|
||||
{
|
||||
// AC: dry_run catches unknown command, not_batchable, unknown param, and a bad reference,
|
||||
// with zero mutation.
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var operations = new JArray
|
||||
{
|
||||
Op("create_gameobject", new JObject { ["name"] = "BatchDry_A" }), // valid
|
||||
Op("no_such_command_xyz"), // unknown command
|
||||
Op("build"), // not_batchable
|
||||
Op("create_gameobject", new JObject { ["bogus_param"] = "x" }), // unknown parameter
|
||||
Op("add_component", new JObject { ["target"] = "$9.instanceId", ["type"] = "Rigidbody" }), // bad ref
|
||||
};
|
||||
|
||||
var response = server.Execute("batch", new JObject
|
||||
{
|
||||
["operations"] = operations,
|
||||
["dry_run"] = true
|
||||
});
|
||||
Assert.IsTrue(response.IsSuccess, $"dry_run should return HTTP success: {response.RawResponse}");
|
||||
|
||||
var batch = response.JsonResponse["result"];
|
||||
Assert.IsTrue(batch["dryRun"]?.ToObject<bool>() ?? false, "result should be marked dryRun");
|
||||
Assert.IsFalse(batch["valid"]?.ToObject<bool>() ?? true, "the batch has invalid ops");
|
||||
Assert.AreEqual(0, batch["applied"]?.ToObject<int>() ?? -1, "dry_run applies nothing");
|
||||
|
||||
var results = batch["results"] as JArray;
|
||||
Assert.IsTrue(results[0]["success"]?.ToObject<bool>() ?? false, "op 0 is valid");
|
||||
StringAssert.Contains("unknown command", results[1]["error"]?.ToString());
|
||||
StringAssert.Contains("not_batchable", results[2]["error"]?.ToString());
|
||||
StringAssert.Contains("unknown parameter", results[3]["error"]?.ToString());
|
||||
StringAssert.Contains("unknown operation", results[4]["error"]?.ToString());
|
||||
|
||||
// Zero mutation: the valid create op must NOT have run.
|
||||
Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchDry_A").Count,
|
||||
"dry_run must not create anything");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Batch_BadReference_ErrorsClearly_AndReverts()
|
||||
{
|
||||
// AC: a bad reference errors clearly. Here op 1 references a nonexistent op; under the
|
||||
// default transactional/abort policy op 0 is reverted.
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var operations = new JArray
|
||||
{
|
||||
Op("create_gameobject", new JObject { ["name"] = "BatchBadRef_X" }),
|
||||
Op("add_component", new JObject { ["target"] = "$5.instanceId", ["type"] = "Rigidbody" }),
|
||||
};
|
||||
|
||||
var response = server.Execute("batch", new JObject { ["operations"] = operations });
|
||||
Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}");
|
||||
|
||||
var batch = response.JsonResponse["result"];
|
||||
var results = batch["results"] as JArray;
|
||||
Assert.IsFalse(results[1]["success"]?.ToObject<bool>() ?? true, "the referencing op must fail");
|
||||
var error = results[1]["error"]?.ToString();
|
||||
StringAssert.Contains("$5.instanceId", error, "the error should name the offending reference");
|
||||
StringAssert.Contains("unknown operation", error, "and explain why it is invalid");
|
||||
|
||||
Assert.IsTrue(batch["reverted"]?.ToObject<bool>() ?? false, "the prior op must be reverted");
|
||||
Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchBadRef_X").Count,
|
||||
"op 0 must be reverted after the bad reference");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Batch_OnErrorContinue_AppliesIndependentOps_NoRevert()
|
||||
{
|
||||
// AC: on_error=continue collects failures, applies independent ops, reports reverted:false.
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var operations = new JArray
|
||||
{
|
||||
Op("create_gameobject", new JObject { ["name"] = "BatchCont_A" }),
|
||||
Op("add_component", new JObject { ["target"] = "$0.instanceId", ["type"] = "NoSuchTypeXYZ" }), // fails
|
||||
Op("create_gameobject", new JObject { ["name"] = "BatchCont_C" }), // independent
|
||||
};
|
||||
|
||||
LogAssert.Expect(LogType.Error, new Regex("add_component' failed: Could not resolve component type 'NoSuchTypeXYZ'"));
|
||||
|
||||
var response = server.Execute("batch", new JObject
|
||||
{
|
||||
["operations"] = operations,
|
||||
["on_error"] = "continue"
|
||||
});
|
||||
Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}");
|
||||
|
||||
var batch = response.JsonResponse["result"];
|
||||
Assert.IsFalse(batch["reverted"]?.ToObject<bool>() ?? true, "continue must not revert");
|
||||
Assert.IsFalse(batch["transactional"]?.ToObject<bool>() ?? true, "continue forces transactional=false");
|
||||
Assert.AreEqual(2, batch["applied"]?.ToObject<int>(), "the two independent creates apply");
|
||||
|
||||
var results = batch["results"] as JArray;
|
||||
Assert.IsTrue(results[0]["success"]?.ToObject<bool>() ?? false);
|
||||
Assert.IsFalse(results[1]["success"]?.ToObject<bool>() ?? true, "the middle op fails");
|
||||
Assert.IsTrue(results[2]["success"]?.ToObject<bool>() ?? false, "a later independent op still runs");
|
||||
|
||||
// Both independent objects must survive (no rollback).
|
||||
Assert.IsNotNull(ResolveGameObject(results[0]), "op 0's object should survive");
|
||||
Assert.IsNotNull(ResolveGameObject(results[2]), "op 2's object should survive");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Batch_ExceedsOperationCap_ReturnsError()
|
||||
{
|
||||
// AC: the 200-op cap is enforced with a structured error (checked before anything runs).
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var operations = new JArray();
|
||||
for (int i = 0; i < 201; i++)
|
||||
operations.Add(Op("create_gameobject", new JObject { ["name"] = "BatchCap_" + i }));
|
||||
|
||||
LogAssert.Expect(LogType.Error, new Regex("batch supports at most 200 operations"));
|
||||
|
||||
var response = server.Execute("batch", new JObject
|
||||
{
|
||||
["operations"] = operations,
|
||||
["dry_run"] = true
|
||||
});
|
||||
|
||||
Assert.IsFalse(response.IsSuccess, "a batch over the op cap must be rejected");
|
||||
StringAssert.Contains("200", response.RawResponse, "the error should state the 200-op cap");
|
||||
Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchCap_0").Count,
|
||||
"an over-cap batch must create nothing");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Batch_AsyncCommand_NotBatchable_AtDryRunAndExecution()
|
||||
{
|
||||
// C1: run_tests/list_tests are async Task commands completed by EditorApplication.update
|
||||
// callbacks, which cannot fire while the batch blocks the main thread — awaiting them
|
||||
// inline would freeze the editor permanently. They must be rejected, not dispatched.
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var operations = new JArray
|
||||
{
|
||||
Op("run_tests", new JObject { ["mode"] = "EditMode" }),
|
||||
Op("list_tests"),
|
||||
};
|
||||
|
||||
// Dry run flags both without executing anything.
|
||||
var response = server.Execute("batch", new JObject
|
||||
{
|
||||
["operations"] = operations,
|
||||
["dry_run"] = true
|
||||
});
|
||||
Assert.IsTrue(response.IsSuccess, $"dry_run should return HTTP success: {response.RawResponse}");
|
||||
var results = response.JsonResponse["result"]["results"] as JArray;
|
||||
foreach (var opResult in results)
|
||||
{
|
||||
Assert.IsFalse(opResult["success"]?.ToObject<bool>() ?? true, "async ops must fail validation");
|
||||
StringAssert.Contains("not_batchable", opResult["error"]?.ToString());
|
||||
StringAssert.Contains("async", opResult["error"]?.ToString());
|
||||
}
|
||||
|
||||
// Execution rejects them the same way — and returns instead of hanging.
|
||||
response = server.Execute("batch", new JObject { ["operations"] = new JArray { Op("run_tests") } });
|
||||
Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}");
|
||||
var exec = response.JsonResponse["result"];
|
||||
Assert.AreEqual(0, exec["applied"]?.ToObject<int>(), "nothing must be dispatched");
|
||||
StringAssert.Contains("not_batchable", exec["results"][0]["error"]?.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Batch_Transactional_RejectsNonRevertibleCommand_NonTransactionalAllowsIt()
|
||||
{
|
||||
// C2: delete_asset mutates the AssetDatabase, which Undo cannot roll back — inside a
|
||||
// transactional batch "reverted: true" would be a lie, so it is rejected. With
|
||||
// transactional:false it runs and reports revertible:false.
|
||||
const string assetPath = "Assets/BatchTests_DeleteTarget.txt";
|
||||
File.WriteAllText(assetPath, "batch delete target");
|
||||
AssetDatabase.ImportAsset(assetPath);
|
||||
try
|
||||
{
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var operations = new JArray
|
||||
{
|
||||
Op("delete_asset", new JObject { ["asset"] = assetPath, ["confirm"] = true }),
|
||||
};
|
||||
|
||||
// Dry run under the default transactional=true flags it.
|
||||
var response = server.Execute("batch", new JObject
|
||||
{
|
||||
["operations"] = operations,
|
||||
["dry_run"] = true
|
||||
});
|
||||
var dry = response.JsonResponse["result"];
|
||||
Assert.IsFalse(dry["valid"]?.ToObject<bool>() ?? true);
|
||||
StringAssert.Contains("not_batchable_transactional", dry["results"][0]["error"]?.ToString());
|
||||
|
||||
// Execution under transactional=true rejects it too; the asset survives.
|
||||
response = server.Execute("batch", new JObject { ["operations"] = operations });
|
||||
var exec = response.JsonResponse["result"];
|
||||
Assert.IsFalse(exec["results"][0]["success"]?.ToObject<bool>() ?? true);
|
||||
StringAssert.Contains("not_batchable_transactional", exec["results"][0]["error"]?.ToString());
|
||||
Assert.IsNotNull(AssetDatabase.LoadMainAssetAtPath(assetPath),
|
||||
"a rejected delete_asset must not touch the asset");
|
||||
|
||||
// transactional:false allows it — with an honest revertible:false on the op.
|
||||
response = server.Execute("batch", new JObject
|
||||
{
|
||||
["operations"] = operations,
|
||||
["transactional"] = false
|
||||
});
|
||||
exec = response.JsonResponse["result"];
|
||||
Assert.IsTrue(exec["results"][0]["success"]?.ToObject<bool>() ?? false,
|
||||
$"transactional:false must allow delete_asset: {exec["results"][0]["error"]}");
|
||||
Assert.IsFalse(exec["results"][0]["revertible"]?.ToObject<bool>() ?? true,
|
||||
"the op must report revertible:false");
|
||||
Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(assetPath), "the asset must be deleted");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (AssetDatabase.LoadMainAssetAtPath(assetPath) != null)
|
||||
AssetDatabase.DeleteAsset(assetPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Batch_OpenScene_AlwaysRejected_EvenNonTransactional()
|
||||
{
|
||||
// C2: open_scene/create_scene CLEAR the Editor Undo stack, destroying the batch's ability
|
||||
// to revert anything before them — they are rejected regardless of the transactional flag.
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var operations = new JArray
|
||||
{
|
||||
Op("open_scene", new JObject { ["path"] = "Assets/Nope.unity" }),
|
||||
};
|
||||
|
||||
var response = server.Execute("batch", new JObject
|
||||
{
|
||||
["operations"] = operations,
|
||||
["dry_run"] = true
|
||||
});
|
||||
var dry = response.JsonResponse["result"];
|
||||
StringAssert.Contains("not_batchable", dry["results"][0]["error"]?.ToString());
|
||||
StringAssert.Contains("Undo stack", dry["results"][0]["error"]?.ToString());
|
||||
|
||||
response = server.Execute("batch", new JObject
|
||||
{
|
||||
["operations"] = operations,
|
||||
["transactional"] = false
|
||||
});
|
||||
var exec = response.JsonResponse["result"];
|
||||
Assert.IsFalse(exec["results"][0]["success"]?.ToObject<bool>() ?? true);
|
||||
StringAssert.Contains("not_batchable", exec["results"][0]["error"]?.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Batch_FirstOpPartialMutation_IsRolledBack()
|
||||
{
|
||||
// I1: an op that mutates and THEN throws leaves partial Undo-registered state even when
|
||||
// it is the first (and only) op — the transactional rollback must run with applied == 0.
|
||||
// set_transform applies 'position' before it parses the invalid 'rotation'.
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var go = Track(new GameObject("BatchPartial_Target"));
|
||||
go.transform.localPosition = Vector3.zero;
|
||||
|
||||
var operations = new JArray
|
||||
{
|
||||
Op("set_transform", new JObject
|
||||
{
|
||||
// Version-stable id: GetInstanceID() is obsolete-as-error from 6000.4.
|
||||
["target"] = PipelineUtils.GetObjectId(go).RawValue,
|
||||
["position"] = new JArray(5f, 6f, 7f),
|
||||
["rotation"] = new JArray(1f, 2f), // invalid: 2 components -> throws after position applied
|
||||
}),
|
||||
};
|
||||
|
||||
LogAssert.Expect(LogType.Error, new Regex("set_transform' failed: 'rotation' must have exactly 3 components"));
|
||||
|
||||
var response = server.Execute("batch", new JObject { ["operations"] = operations });
|
||||
Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}");
|
||||
|
||||
var batch = response.JsonResponse["result"];
|
||||
Assert.AreEqual(0, batch["applied"]?.ToObject<int>(), "the only op failed");
|
||||
Assert.IsTrue(batch["reverted"]?.ToObject<bool>() ?? false,
|
||||
"a transactional failure must revert even when applied == 0");
|
||||
Assert.AreEqual(0f, go.transform.localPosition.magnitude, 0.0001f,
|
||||
"the failing op's partial position change must be reverted");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Batch_TimeBudgetExceeded_SkipsRemainingOps_AndRevertsTransactional()
|
||||
{
|
||||
// I2: the cooperative time budget is checked before each op after the first. Budget 0 is
|
||||
// exhausted immediately after op 0, so ops 1..n are skipped and the transactional batch
|
||||
// rolls back with a batch-level error.
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var operations = new JArray
|
||||
{
|
||||
Op("create_gameobject", new JObject { ["name"] = "BatchBudget_A" }),
|
||||
Op("create_gameobject", new JObject { ["name"] = "BatchBudget_B" }),
|
||||
Op("create_gameobject", new JObject { ["name"] = "BatchBudget_C" }),
|
||||
};
|
||||
|
||||
var response = server.Execute("batch", new JObject
|
||||
{
|
||||
["operations"] = operations,
|
||||
["time_budget_ms"] = 0
|
||||
});
|
||||
Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}");
|
||||
|
||||
var batch = response.JsonResponse["result"];
|
||||
StringAssert.Contains("batch time budget exceeded", batch["error"]?.ToString());
|
||||
Assert.AreEqual(1, batch["applied"]?.ToObject<int>(), "only op 0 runs on a zero budget");
|
||||
Assert.IsTrue(batch["reverted"]?.ToObject<bool>() ?? false, "budget abort is a transactional failure");
|
||||
|
||||
var results = batch["results"] as JArray;
|
||||
Assert.AreEqual(3, results.Count);
|
||||
Assert.IsTrue(results[0]["success"]?.ToObject<bool>() ?? false, "op 0 always gets to run");
|
||||
Assert.IsTrue(results[1]["skipped"]?.ToObject<bool>() ?? false, "op 1 must be skipped");
|
||||
Assert.IsTrue(results[2]["skipped"]?.ToObject<bool>() ?? false, "op 2 must be skipped");
|
||||
|
||||
Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchBudget_A").Count,
|
||||
"op 0 must be reverted by the budget-abort rollback");
|
||||
Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchBudget_B").Count);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Batch_ReferenceToFailedOp_ErrorsClearly()
|
||||
{
|
||||
// I3: under on_error=continue an op can fail without stopping the batch; a later $ref
|
||||
// into the failed op must error explicitly instead of silently resolving to null.
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var operations = new JArray
|
||||
{
|
||||
Op("add_component", new JObject { ["target"] = "/NoSuchObjectXYZ_00", ["type"] = "Rigidbody" }), // fails
|
||||
Op("rename_gameobject", new JObject { ["target"] = "$0.instanceId", ["name"] = "Renamed" }),
|
||||
};
|
||||
|
||||
LogAssert.Expect(LogType.Error, new Regex("add_component' failed:"));
|
||||
|
||||
var response = server.Execute("batch", new JObject
|
||||
{
|
||||
["operations"] = operations,
|
||||
["on_error"] = "continue"
|
||||
});
|
||||
Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}");
|
||||
|
||||
var results = response.JsonResponse["result"]["results"] as JArray;
|
||||
Assert.IsFalse(results[0]["success"]?.ToObject<bool>() ?? true, "op 0 must fail");
|
||||
Assert.IsFalse(results[1]["success"]?.ToObject<bool>() ?? true, "the referencing op must fail");
|
||||
var error = results[1]["error"]?.ToString();
|
||||
StringAssert.Contains("$0.instanceId", error, "the error should name the offending reference");
|
||||
StringAssert.Contains("failed; its result cannot be referenced", error,
|
||||
"and say the referenced op failed");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolver_ReferenceToVoidResult_Errors_WholeValueAndPath()
|
||||
{
|
||||
// I3: a SUCCEEDED op with a null result (void command) is distinct from a failed op —
|
||||
// referencing it errors with "returned no result", for whole-value and path refs alike.
|
||||
var operations = new List<BatchOperationInput>
|
||||
{
|
||||
new BatchOperationInput { Command = "noop_a" },
|
||||
new BatchOperationInput { Command = "noop_b" },
|
||||
};
|
||||
var outcomes = new List<BatchOperationResult>
|
||||
{
|
||||
new BatchOperationResult { Command = "noop_a", Success = true },
|
||||
new BatchOperationResult { Command = "noop_b" },
|
||||
};
|
||||
var results = new JToken[] { JValue.CreateNull(), null };
|
||||
var idToIndex = new Dictionary<string, int>();
|
||||
|
||||
var wholeRef = new JObject { ["value"] = "$0" };
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
BatchReferenceResolver.Resolve(wholeRef, 1, operations, idToIndex, results, outcomes));
|
||||
StringAssert.Contains("returned no result", ex.Message);
|
||||
|
||||
var pathRef = new JObject { ["value"] = "$0.instanceId" };
|
||||
ex = Assert.Throws<ArgumentException>(() =>
|
||||
BatchReferenceResolver.Resolve(pathRef, 1, operations, idToIndex, results, outcomes));
|
||||
StringAssert.Contains("returned no result", ex.Message);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolver_ReferenceToFailedOp_Errors_WholeValueAndPath()
|
||||
{
|
||||
// I3: whole-value refs into a FAILED op must error just like path refs (the end-to-end
|
||||
// test above covers the path form; this pins the whole-value form).
|
||||
var operations = new List<BatchOperationInput>
|
||||
{
|
||||
new BatchOperationInput { Command = "noop_a" },
|
||||
new BatchOperationInput { Command = "noop_b" },
|
||||
};
|
||||
var outcomes = new List<BatchOperationResult>
|
||||
{
|
||||
new BatchOperationResult { Command = "noop_a", Success = false, Error = "boom" },
|
||||
new BatchOperationResult { Command = "noop_b" },
|
||||
};
|
||||
var results = new JToken[] { null, null };
|
||||
var idToIndex = new Dictionary<string, int>();
|
||||
|
||||
var wholeRef = new JObject { ["value"] = "$0" };
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
BatchReferenceResolver.Resolve(wholeRef, 1, operations, idToIndex, results, outcomes));
|
||||
StringAssert.Contains("failed; its result cannot be referenced", ex.Message);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Batch_RuntimeOnlyCommand_NotBatchable()
|
||||
{
|
||||
// I5: runtime-only commands (Player server surface) are discoverable via the registry but
|
||||
// must not be dispatchable through an editor-server batch.
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("batch", new JObject
|
||||
{
|
||||
["operations"] = new JArray { Op("set_timescale", new JObject { ["scale"] = 0.5f }) },
|
||||
["dry_run"] = true
|
||||
});
|
||||
|
||||
var dry = response.JsonResponse["result"];
|
||||
Assert.IsFalse(dry["valid"]?.ToObject<bool>() ?? true);
|
||||
var error = dry["results"][0]["error"]?.ToString();
|
||||
StringAssert.Contains("not_batchable", error);
|
||||
StringAssert.Contains("runtime-only", error);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Batch_MenuCommand_NotBatchable()
|
||||
{
|
||||
// I6: menu items can open modal dialogs, which would wedge an unattended batch.
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("batch", new JObject
|
||||
{
|
||||
["operations"] = new JArray { Op("menu", new JObject { ["path"] = "File/Save" }) },
|
||||
["dry_run"] = true
|
||||
});
|
||||
|
||||
var dry = response.JsonResponse["result"];
|
||||
Assert.IsFalse(dry["valid"]?.ToObject<bool>() ?? true);
|
||||
StringAssert.Contains("not_batchable", dry["results"][0]["error"]?.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Batch_NumericOpId_Rejected()
|
||||
{
|
||||
// M3: a purely numeric id would be ambiguous with a 0-based index selector in "$<id-or-index>".
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var operations = new JArray
|
||||
{
|
||||
Op("create_gameobject", new JObject { ["name"] = "BatchId_A" }, id: "0"),
|
||||
};
|
||||
|
||||
LogAssert.Expect(LogType.Error, new Regex("ids must match"));
|
||||
|
||||
var response = server.Execute("batch", new JObject
|
||||
{
|
||||
["operations"] = operations,
|
||||
["dry_run"] = true
|
||||
});
|
||||
|
||||
Assert.IsFalse(response.IsSuccess, "a numeric op id must be rejected outright");
|
||||
StringAssert.Contains("ids must match", response.RawResponse);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Batch_DollarDollar_EscapesToLiteralDollar()
|
||||
{
|
||||
// M7: "$$..." escapes a literal '$' — the op receives "$literal", not a reference.
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var operations = new JArray
|
||||
{
|
||||
Op("create_gameobject", new JObject { ["name"] = "$$literal" }),
|
||||
};
|
||||
|
||||
var response = server.Execute("batch", new JObject { ["operations"] = operations });
|
||||
Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}");
|
||||
|
||||
var results = response.JsonResponse["result"]["results"] as JArray;
|
||||
var go = ResolveGameObject(results[0]);
|
||||
Assert.IsNotNull(go, "op 0's GameObject should be resolvable");
|
||||
Assert.AreEqual("$literal", go.name, "\"$$literal\" must unescape to the literal \"$literal\"");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Batch_IdBasedReference_Resolves()
|
||||
{
|
||||
// M7: references can select the target op by its id, not only by index.
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var operations = new JArray
|
||||
{
|
||||
Op("create_gameobject", new JObject { ["name"] = "BatchIdRef_Root" }, id: "root"),
|
||||
Op("add_component", new JObject { ["target"] = "$root.instanceId", ["type"] = "Rigidbody" }),
|
||||
};
|
||||
|
||||
var response = server.Execute("batch", new JObject { ["operations"] = operations });
|
||||
Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}");
|
||||
|
||||
var batch = response.JsonResponse["result"];
|
||||
Assert.AreEqual(2, batch["applied"]?.ToObject<int>(),
|
||||
$"both ops should apply: {batch["results"]}");
|
||||
|
||||
var root = ResolveGameObject((batch["results"] as JArray)[0]);
|
||||
Assert.IsNotNull(root);
|
||||
Assert.IsNotNull(root.GetComponent<Rigidbody>(), "$root.instanceId must resolve by op id");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Batch_ResultFields_ProjectsOpResult()
|
||||
{
|
||||
// M7: result_fields keeps only the requested result fields for the op (context economy).
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var operations = new JArray
|
||||
{
|
||||
Op("create_gameobject", new JObject { ["name"] = "BatchProject_A" }),
|
||||
};
|
||||
|
||||
var response = server.Execute("batch", new JObject
|
||||
{
|
||||
["operations"] = operations,
|
||||
["result_fields"] = new JObject { ["0"] = new JArray("instanceId") }
|
||||
});
|
||||
Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}");
|
||||
|
||||
var results = response.JsonResponse["result"]["results"] as JArray;
|
||||
var projected = results[0]["result"] as JObject;
|
||||
Assert.IsNotNull(projected, "the projected result should be an object");
|
||||
Assert.IsNotNull(projected["instanceId"], "the requested field must be kept");
|
||||
Assert.IsNull(projected["hierarchyPath"], "unrequested fields must be dropped");
|
||||
Assert.IsNull(projected["globalId"], "unrequested fields must be dropped");
|
||||
|
||||
// Cleanup: the projection kept instanceId, so the object is still resolvable.
|
||||
Assert.IsNotNull(ResolveGameObject(results[0]));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Batch_OversizedOpResult_Truncated_ButReferencesStillResolve()
|
||||
{
|
||||
// M7: an op result over the per-op cap is replaced by a truncation marker in the envelope,
|
||||
// but later $refs resolve against the FULL (untruncated) result.
|
||||
const string bigPath = "Assets/BatchTests_BigFile.txt";
|
||||
File.WriteAllText(bigPath, new string('x', 20000)); // > 16 KiB serialized
|
||||
try
|
||||
{
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var operations = new JArray
|
||||
{
|
||||
Op("read_text_file", new JObject { ["path"] = bigPath }),
|
||||
// References a scalar of the oversized result — must still resolve.
|
||||
Op("create_gameobject", new JObject { ["name"] = "$0.assetPath" }),
|
||||
};
|
||||
|
||||
var response = server.Execute("batch", new JObject { ["operations"] = operations });
|
||||
Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}");
|
||||
|
||||
var results = response.JsonResponse["result"]["results"] as JArray;
|
||||
Assert.IsTrue(results[0]["resultTruncated"]?.ToObject<bool>() ?? false,
|
||||
"the oversized read_text_file result must be truncated");
|
||||
Assert.IsTrue(results[0]["result"]?["resultTruncated"]?.ToObject<bool>() ?? false,
|
||||
"the result must be replaced by the truncation marker");
|
||||
|
||||
Assert.IsTrue(results[1]["success"]?.ToObject<bool>() ?? false,
|
||||
$"the referencing op must still succeed: {results[1]["error"]}");
|
||||
var go = ResolveGameObject(results[1]);
|
||||
Assert.IsNotNull(go);
|
||||
Assert.AreEqual(bigPath, go.name, "$0.assetPath must resolve against the untruncated result");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(bigPath))
|
||||
File.Delete(bigPath);
|
||||
if (File.Exists(bigPath + ".meta"))
|
||||
File.Delete(bigPath + ".meta");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Batch_NestedBatch_Rejected()
|
||||
{
|
||||
// M7: batch-in-batch is excluded (recursion/auditing hazard).
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("batch", new JObject
|
||||
{
|
||||
["operations"] = new JArray { Op("batch", new JObject { ["operations"] = new JArray() }) },
|
||||
["dry_run"] = true
|
||||
});
|
||||
|
||||
var dry = response.JsonResponse["result"];
|
||||
Assert.IsFalse(dry["valid"]?.ToObject<bool>() ?? true);
|
||||
StringAssert.Contains("not_batchable", dry["results"][0]["error"]?.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Batch_NonTransactional_Abort_KeepsAppliedOps_SkipsRest()
|
||||
{
|
||||
// M7: transactional:false + on_error=abort stops at the failing op but keeps what was
|
||||
// already applied (no rollback), and marks the rest skipped.
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var operations = new JArray
|
||||
{
|
||||
Op("create_gameobject", new JObject { ["name"] = "BatchNoTx_A" }),
|
||||
Op("add_component", new JObject { ["target"] = "$0.instanceId", ["type"] = "NoSuchTypeXYZ" }), // fails
|
||||
Op("create_gameobject", new JObject { ["name"] = "BatchNoTx_C" }),
|
||||
};
|
||||
|
||||
LogAssert.Expect(LogType.Error, new Regex("add_component' failed: Could not resolve component type 'NoSuchTypeXYZ'"));
|
||||
|
||||
var response = server.Execute("batch", new JObject
|
||||
{
|
||||
["operations"] = operations,
|
||||
["transactional"] = false
|
||||
});
|
||||
Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}");
|
||||
|
||||
var batch = response.JsonResponse["result"];
|
||||
Assert.IsFalse(batch["reverted"]?.ToObject<bool>() ?? true, "transactional:false must not revert");
|
||||
Assert.AreEqual(1, batch["applied"]?.ToObject<int>());
|
||||
|
||||
var results = batch["results"] as JArray;
|
||||
Assert.IsTrue(results[0]["success"]?.ToObject<bool>() ?? false);
|
||||
Assert.IsFalse(results[1]["success"]?.ToObject<bool>() ?? true, "op 1 must fail");
|
||||
Assert.IsTrue(results[2]["skipped"]?.ToObject<bool>() ?? false, "op 2 must be skipped (abort)");
|
||||
|
||||
Assert.IsNotNull(ResolveGameObject(results[0]), "op 0's object must survive (no rollback)");
|
||||
Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchNoTx_C").Count,
|
||||
"op 2 never ran");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Batch_InvalidResultFieldsPath_DoesNotFailTheAppliedOp()
|
||||
{
|
||||
// Review finding: a malformed result_fields JSONPath threw AFTER the op had mutated
|
||||
// state, and the outer catch flipped the op's Success to false — rolling back a
|
||||
// succeeded op under on_error=abort. The projection must report the bad path instead.
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var operations = new JArray { Op("create_gameobject", new JObject { ["name"] = "BatchProj_BadPath" }) };
|
||||
var response = server.Execute("batch", new JObject
|
||||
{
|
||||
["operations"] = operations,
|
||||
["result_fields"] = new JObject { ["0"] = new JArray("foo[") }
|
||||
});
|
||||
Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}");
|
||||
|
||||
var batch = response.JsonResponse["result"];
|
||||
var results = batch["results"] as JArray;
|
||||
Assert.IsTrue(results[0]["success"]?.ToObject<bool>() ?? false,
|
||||
"a malformed projection path must not flip an already-applied op to failure");
|
||||
Assert.IsFalse(batch["reverted"]?.ToObject<bool>() ?? true,
|
||||
"and must not trigger a rollback of the succeeded op");
|
||||
StringAssert.Contains("foo[", results[0]["result"]?["invalidResultFields"]?[0]?.ToString(),
|
||||
"the bad path is reported inside the projection");
|
||||
|
||||
var survivors = GameObjectCommands.FindGameObjects(name: "BatchProj_BadPath");
|
||||
Assert.AreEqual(1, survivors.Count, "the op's mutation must survive");
|
||||
Track(GameObject.Find("BatchProj_BadPath"));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Batch_TransactionalRevert_LeavesUndoStackClean()
|
||||
{
|
||||
// Review finding: after Undo.RevertAllDownToGroup discarded the batch group, the
|
||||
// enclosing AuthoringUndoScope still collapsed that stale group id on dispose. The
|
||||
// scope is now canceled after a revert; the contract this pins: the next PerformUndo
|
||||
// must undo the USER's previous operation, not be absorbed by a stray batch group.
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
Undo.IncrementCurrentGroup();
|
||||
var marker = Track(new GameObject("BatchUndoStack_Marker"));
|
||||
Undo.RegisterCreatedObjectUndo(marker, "create marker");
|
||||
Undo.IncrementCurrentGroup();
|
||||
|
||||
var operations = new JArray
|
||||
{
|
||||
Op("create_gameobject", new JObject { ["name"] = "BatchUndoStack_Tx" }),
|
||||
Op("add_component", new JObject { ["target"] = "$0.instanceId", ["type"] = "NoSuchTypeXYZ" }),
|
||||
};
|
||||
LogAssert.Expect(LogType.Error, new Regex("add_component' failed: Could not resolve component type 'NoSuchTypeXYZ'"));
|
||||
var response = server.Execute("batch", new JObject { ["operations"] = operations });
|
||||
Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}");
|
||||
Assert.IsTrue(response.JsonResponse["result"]["reverted"]?.ToObject<bool>() ?? false,
|
||||
"precondition: the transactional batch reverted");
|
||||
|
||||
Undo.PerformUndo();
|
||||
Assert.IsTrue(marker == null,
|
||||
"PerformUndo after a reverted batch must undo the previous user operation " +
|
||||
"(the marker creation) — a surviving marker means a stray batch group absorbed it");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ProjectResult_MeasuresUtf8Bytes_NotUtf16Chars()
|
||||
{
|
||||
// Review finding: the per-op cap compared UTF-16 char counts while the wire is UTF-8 —
|
||||
// a CJK payload at ~12k chars is ~36 KB on the wire and sailed under the 16 KiB cap.
|
||||
var op = new BatchOperationInput { Command = "noop" };
|
||||
var opResult = new BatchOperationResult();
|
||||
var budget = int.MaxValue; // isolate the per-op cap from the aggregate budget
|
||||
var cjk = new string('\u597D', 12_000); // 12k chars, ~36 KB UTF-8
|
||||
|
||||
var projected = BatchCommand.ProjectResult(cjk, JToken.FromObject(cjk), op, 0, null, opResult, ref budget);
|
||||
|
||||
Assert.IsTrue(opResult.ResultTruncated == true,
|
||||
"a payload under the char count but over the UTF-8 byte cap must be truncated");
|
||||
var marker = (JObject)JToken.FromObject(projected);
|
||||
Assert.Greater(marker["length"]?.ToObject<int>() ?? 0, 16 * 1024, "length reports wire bytes");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ProjectResult_AggregateBudget_ElidesLaterResults()
|
||||
{
|
||||
// Review finding: nothing bounded the SUM of per-op results (200 ops x 16 KiB each is a
|
||||
// ~3.2 MB reply). Once the aggregate budget is exhausted, later results become markers.
|
||||
var op = new BatchOperationInput { Command = "noop" };
|
||||
var budget = BatchCommand.MaxBatchResultBytes;
|
||||
var big = new string('a', 15_000); // under the per-op cap; ~17 of these exhaust the budget
|
||||
|
||||
object last = null;
|
||||
BatchOperationResult lastResult = null;
|
||||
var truncatedAt = -1;
|
||||
for (var i = 0; i < 30; i++)
|
||||
{
|
||||
lastResult = new BatchOperationResult();
|
||||
last = BatchCommand.ProjectResult(big, JToken.FromObject(big), op, i, null, lastResult, ref budget);
|
||||
if (lastResult.ResultTruncated == true) { truncatedAt = i; break; }
|
||||
}
|
||||
|
||||
Assert.GreaterOrEqual(truncatedAt, 2, "several results must fit before the budget trips");
|
||||
Assert.AreNotEqual(-1, truncatedAt, "the aggregate budget must eventually trip");
|
||||
StringAssert.Contains("aggregate result budget", ((JObject)JToken.FromObject(last))["error"]?.ToString(),
|
||||
"the marker explains the aggregate elision");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7dffec4aa4e549679a30d77044ee314d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Editor;
|
||||
using Unity.Pipeline.Editor.Commands.Build;
|
||||
using UnityEditor;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the build-configuration commands (CLI-204): <c>list_build_targets</c>,
|
||||
/// <c>get_build_settings</c>, <c>set_build_settings</c> (dry run only — no real mutation),
|
||||
/// <c>list_build_profiles</c>, and the <c>switch_build_target</c> command surface. The read-only
|
||||
/// commands are executed directly on the test (main) thread; nothing here triggers a build or switch.
|
||||
/// </summary>
|
||||
class BuildConfigCommandsTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp() => CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
|
||||
|
||||
[Test]
|
||||
public void NewCommands_AreDiscovered()
|
||||
{
|
||||
var names = CommandRegistry.DiscoverCommands().Select(c => c.Name).ToList();
|
||||
|
||||
foreach (var expected in new[]
|
||||
{
|
||||
"list_build_targets", "get_build_settings", "set_build_settings",
|
||||
"list_build_profiles", "switch_build_target", "switch_build_target_status"
|
||||
})
|
||||
{
|
||||
CollectionAssert.Contains(names, expected, $"Should discover the {expected} command");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SwitchBuildTarget_RequiresTarget_AndConfirm()
|
||||
{
|
||||
var switchCmd = CommandRegistry.DiscoverCommands().First(c => c.Name == "switch_build_target");
|
||||
|
||||
var target = switchCmd.Parameters.First(p => p.Name == "target");
|
||||
Assert.IsTrue(target.Required, "switch_build_target 'target' must be required");
|
||||
|
||||
// Missing confirm must be refused without attempting the switch.
|
||||
var result = JObjectOf(SwitchBuildTargetCommand.SwitchBuildTarget(
|
||||
EditorUserBuildSettings.activeBuildTarget == UnityEditor.BuildTarget.StandaloneWindows64
|
||||
? "StandaloneLinux64"
|
||||
: "StandaloneWindows64",
|
||||
confirm: false));
|
||||
|
||||
Assert.AreEqual("error", (string)result["status"]);
|
||||
Assert.AreEqual(false, (bool)result["success"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ListBuildTargets_IncludesActiveTarget_AsInstalled()
|
||||
{
|
||||
var targets = (List<BuildTargetInfo>)BuildConfigCommands.ListBuildTargets();
|
||||
Assert.IsNotNull(targets);
|
||||
Assert.Greater(targets.Count, 0, "should enumerate at least one build target");
|
||||
|
||||
var active = EditorUserBuildSettings.activeBuildTarget.ToString();
|
||||
var entry = targets.FirstOrDefault(t => t.Name == active);
|
||||
Assert.IsNotNull(entry, "the active build target should appear in the list");
|
||||
Assert.IsTrue(entry.IsInstalled, "the active build target must be installed");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetBuildSettings_ReportsActiveTargetAndScenes()
|
||||
{
|
||||
var settings = (BuildSettingsResult)BuildConfigCommands.GetBuildSettings();
|
||||
Assert.IsNotNull(settings);
|
||||
Assert.AreEqual(EditorUserBuildSettings.activeBuildTarget.ToString(), settings.ActiveBuildTarget);
|
||||
Assert.IsNotNull(settings.Scenes, "scene list should be present (may be empty)");
|
||||
Assert.IsNotNull(settings.Il2CppCodeGeneration);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetBuildSettings_DryRun_ReportsPlan_WithoutMutating()
|
||||
{
|
||||
var before = EditorUserBuildSettings.development;
|
||||
|
||||
var input = new SetBuildSettingsInput { DevelopmentBuild = !before };
|
||||
var result = (SetBuildSettingsResult)BuildConfigCommands.SetBuildSettings(input, dryRun: true);
|
||||
|
||||
Assert.IsTrue(result.Success);
|
||||
Assert.IsTrue(result.DryRun);
|
||||
Assert.IsTrue(result.Applied.ContainsKey("developmentBuild"),
|
||||
"the flipped field should appear in the dry-run 'applied' plan");
|
||||
Assert.AreEqual(before, EditorUserBuildSettings.development,
|
||||
"a dry run must not change EditorUserBuildSettings");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetBuildSettings_NoSettings_Fails()
|
||||
{
|
||||
var result = (SetBuildSettingsResult)BuildConfigCommands.SetBuildSettings(null, dryRun: false);
|
||||
Assert.IsFalse(result.Success);
|
||||
}
|
||||
|
||||
static Newtonsoft.Json.Linq.JObject JObjectOf(object value) =>
|
||||
Newtonsoft.Json.Linq.JObject.FromObject(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f6dcfbbcfcc4e70428b62c04568efe8b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c83fc76f10b96db43a56fd1bbf79c646
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,428 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.IO;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Editor.Commands.Capture;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor.Capture
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the visual-feedback commands (CLI-199), exercised directly and via PipelineClient.
|
||||
/// Render tests are GPU-gated: under batchmode/headless the graphics device is
|
||||
/// <see cref="GraphicsDeviceType.Null"/> and the tests self-ignore rather than fail.
|
||||
/// </summary>
|
||||
class CaptureCommandsTests
|
||||
{
|
||||
private const string CameraName = "CLI199_Cam";
|
||||
private const string SaveFolder = "Assets/CLI199_CaptureTests";
|
||||
|
||||
// PNG file signature: 0x89 'P' 'N' 'G' 0x0D 0x0A 0x1A 0x0A.
|
||||
private static readonly byte[] PngSignature = { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
|
||||
|
||||
private GameObject m_CameraObject;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
m_CameraObject = new GameObject(CameraName);
|
||||
m_CameraObject.AddComponent<Camera>();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
if (m_CameraObject != null)
|
||||
UnityEngine.Object.DestroyImmediate(m_CameraObject);
|
||||
|
||||
if (AssetDatabase.IsValidFolder(SaveFolder))
|
||||
AssetDatabase.DeleteAsset(SaveFolder);
|
||||
}
|
||||
|
||||
private static string AbsolutePath(string projectRelative) =>
|
||||
Path.Combine(ProjectPaths.ProjectRoot, projectRelative);
|
||||
|
||||
private static bool IsHeadless => SystemInfo.graphicsDeviceType == GraphicsDeviceType.Null;
|
||||
|
||||
private static void AssertPngSignature(byte[] bytes)
|
||||
{
|
||||
Assert.GreaterOrEqual(bytes.Length, PngSignature.Length, "PNG payload too short to contain a signature");
|
||||
for (var i = 0; i < PngSignature.Length; i++)
|
||||
Assert.AreEqual(PngSignature[i], bytes[i], $"PNG signature mismatch at byte {i}");
|
||||
}
|
||||
|
||||
#region Direct
|
||||
|
||||
[Test]
|
||||
public void CaptureGameView_NamedCamera_ReturnsPng()
|
||||
{
|
||||
if (IsHeadless)
|
||||
Assert.Ignore("No GPU in batchmode");
|
||||
|
||||
var result = CaptureCommands.CaptureGameView(64, 64, CameraName);
|
||||
|
||||
Assert.IsNotNull(result, "Capture should return a result");
|
||||
Assert.IsNotEmpty(result.Base64, "Base64 payload should be non-empty");
|
||||
Assert.AreEqual(64, result.Width, "Width should match the requested size");
|
||||
Assert.AreEqual(64, result.Height, "Height should match the requested size");
|
||||
Assert.AreEqual("png", result.Encoding);
|
||||
Assert.AreEqual($"camera:{CameraName}", result.Source);
|
||||
Assert.IsNull(result.SavedPath, "No savePath was requested");
|
||||
|
||||
var bytes = Convert.FromBase64String(result.Base64);
|
||||
AssertPngSignature(bytes);
|
||||
Assert.AreEqual(bytes.Length, result.Bytes, "Reported byte length should match decoded payload");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CaptureSceneView_ReturnsPng()
|
||||
{
|
||||
if (IsHeadless)
|
||||
Assert.Ignore("No GPU in batchmode");
|
||||
|
||||
var sv = SceneView.lastActiveSceneView;
|
||||
if (sv == null)
|
||||
Assert.Ignore("No Scene View");
|
||||
|
||||
var result = CaptureCommands.CaptureSceneView(64, 64);
|
||||
|
||||
Assert.IsNotNull(result, "Capture should return a result");
|
||||
Assert.IsNotEmpty(result.Base64, "Base64 payload should be non-empty");
|
||||
Assert.AreEqual("sceneView", result.Source);
|
||||
|
||||
var bytes = Convert.FromBase64String(result.Base64);
|
||||
AssertPngSignature(bytes);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CaptureGameView_SavePath_OmitsBase64_AndWritesPng()
|
||||
{
|
||||
if (IsHeadless)
|
||||
Assert.Ignore("No GPU in batchmode");
|
||||
|
||||
var result = CaptureCommands.CaptureGameView(64, 64, CameraName, $"{SaveFolder}/path_only.png");
|
||||
|
||||
Assert.IsNull(result.Base64, "save_path without include_inline_image should omit the base64 payload");
|
||||
Assert.IsNotNull(result.SavedPath, "SavedPath should be set");
|
||||
|
||||
var fileBytes = File.ReadAllBytes(AbsolutePath(result.SavedPath));
|
||||
AssertPngSignature(fileBytes);
|
||||
Assert.AreEqual(fileBytes.Length, result.Bytes, "Reported byte length should match the file");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CaptureGameView_SavePath_IncludeImage_ReturnsFileAndBase64()
|
||||
{
|
||||
if (IsHeadless)
|
||||
Assert.Ignore("No GPU in batchmode");
|
||||
|
||||
var result = CaptureCommands.CaptureGameView(64, 64, CameraName, $"{SaveFolder}/both.png", includeInlineImage: true);
|
||||
|
||||
Assert.IsNotEmpty(result.Base64, "include_inline_image=true should return the base64 payload");
|
||||
Assert.IsNotNull(result.SavedPath, "SavedPath should be set");
|
||||
AssertPngSignature(Convert.FromBase64String(result.Base64));
|
||||
AssertPngSignature(File.ReadAllBytes(AbsolutePath(result.SavedPath)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CaptureGameView_MaxResolution_ClampsInlineRender()
|
||||
{
|
||||
if (IsHeadless)
|
||||
Assert.Ignore("No GPU in batchmode");
|
||||
|
||||
// No save_path: the inline image is the only artifact, so the cap applies to the render.
|
||||
var result = CaptureCommands.CaptureGameView(128, 64, CameraName, maxResolution: 32);
|
||||
|
||||
Assert.AreEqual(32, result.Width, "Long edge should be clamped to max_resolution");
|
||||
Assert.AreEqual(16, result.Height, "Short edge should keep the aspect ratio");
|
||||
AssertPngSignature(Convert.FromBase64String(result.Base64));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CaptureGameView_SavePath_MaxResolution_DownscalesInlineOnly()
|
||||
{
|
||||
if (IsHeadless)
|
||||
Assert.Ignore("No GPU in batchmode");
|
||||
|
||||
var result = CaptureCommands.CaptureGameView(128, 64, CameraName, $"{SaveFolder}/full.png",
|
||||
includeInlineImage: true, maxResolution: 32);
|
||||
|
||||
Assert.AreEqual(128, result.Width, "The saved file keeps the requested resolution");
|
||||
Assert.AreEqual(32, result.InlineWidth, "The inline copy is downscaled to max_resolution");
|
||||
Assert.AreEqual(16, result.InlineHeight, "The inline copy keeps the aspect ratio");
|
||||
AssertPngSignature(Convert.FromBase64String(result.Base64));
|
||||
AssertPngSignature(File.ReadAllBytes(AbsolutePath(result.SavedPath)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CaptureSceneView_SavePath_OmitsBase64_AndWritesPng()
|
||||
{
|
||||
if (IsHeadless)
|
||||
Assert.Ignore("No GPU in batchmode");
|
||||
|
||||
var sv = SceneView.lastActiveSceneView;
|
||||
if (sv == null)
|
||||
Assert.Ignore("No Scene View");
|
||||
|
||||
var result = CaptureCommands.CaptureSceneView(64, 64, $"{SaveFolder}/scene_path_only.png");
|
||||
|
||||
Assert.IsNull(result.Base64, "save_path without include_inline_image should omit the base64 payload");
|
||||
Assert.IsNotNull(result.SavedPath, "SavedPath should be set");
|
||||
AssertPngSignature(File.ReadAllBytes(AbsolutePath(result.SavedPath)));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Source routing (AUTHAPI-10)
|
||||
|
||||
[Test]
|
||||
public void CaptureGameView_ScreenSource_InEditMode_Throws()
|
||||
{
|
||||
// Overlay/screen capture is a Play-Mode operation; in Edit Mode it must fail with a clear
|
||||
// message (this guard runs before the GPU check, so the message is deterministic headless too).
|
||||
var ex = Assert.Throws<InvalidOperationException>(
|
||||
() => CaptureCommands.CaptureGameView(64, 64, source: "screen"));
|
||||
StringAssert.Contains("Play Mode", ex.Message);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CaptureGameView_UnknownSource_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(
|
||||
() => CaptureCommands.CaptureGameView(64, 64, source: "bogus"));
|
||||
StringAssert.Contains("Unknown source", ex.Message);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ViaClient
|
||||
|
||||
[Test]
|
||||
public void CaptureGameView_ViaClient_Succeeds()
|
||||
{
|
||||
if (IsHeadless)
|
||||
Assert.Ignore("No GPU in batchmode");
|
||||
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("capture_game_view", new { width = 64, height = 64, camera = CameraName });
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, $"capture_game_view should succeed: {response.Error}");
|
||||
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
|
||||
Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CaptureGameView_ViaClient_SavePath_SerializedResultOmitsBase64Key()
|
||||
{
|
||||
if (IsHeadless)
|
||||
Assert.Ignore("No GPU in batchmode");
|
||||
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("capture_game_view",
|
||||
new { width = 64, height = 64, camera = CameraName, save_path = $"{SaveFolder}/via_client.png" });
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, $"capture_game_view should succeed: {response.Error}");
|
||||
var result = (JObject)response.JsonResponse["result"];
|
||||
Assert.IsFalse(result.ContainsKey("base64"),
|
||||
"A path-only result must not carry a base64 key on the wire (AUTHAPI-8)");
|
||||
Assert.IsNotNull(result["savedPath"]?.ToString(), "savedPath should be present");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Screen capture in Play Mode (AUTHAPI-10)
|
||||
|
||||
// Draws a full-screen two-tone quad via IMGUI. Like a Screen Space - Overlay canvas it
|
||||
// composites straight to the screen and never renders through a camera — the case AUTHAPI-10
|
||||
// covers — so it lets the test prove overlay content shows up in a screen capture but not a
|
||||
// camera capture, without a uGUI dependency.
|
||||
//
|
||||
// The tones are chosen to catch two capture-path bugs a flat saturated color (the old pure
|
||||
// red) is blind to:
|
||||
// * MID-GRAYS, because a primary like (255,0,0) is invariant under gamma conversion — 128
|
||||
// turns into ~188 when the path double-encodes gamma (the washout Thomas reported), so
|
||||
// asserting captured luminance pins the tone round-trip.
|
||||
// * TWO DIFFERENT tones (light top, dark bottom), because a uniform screen is invariant
|
||||
// under vertical flips — CaptureScreenshotIntoRenderTexture returns GPU-UV-oriented
|
||||
// content that must be un-flipped on D3D/Metal/Vulkan, so asserting which half is which
|
||||
// pins the orientation.
|
||||
private static readonly Color32 OverlayTopGray = new Color32(128, 128, 128, 255);
|
||||
private static readonly Color32 OverlayBottomGray = new Color32(64, 64, 64, 255);
|
||||
|
||||
private class FullScreenOverlay : MonoBehaviour
|
||||
{
|
||||
private Texture2D m_TopTex;
|
||||
private Texture2D m_BottomTex;
|
||||
|
||||
private static Texture2D Solid(Color32 c)
|
||||
{
|
||||
var tex = new Texture2D(1, 1);
|
||||
tex.SetPixels32(new[] { c });
|
||||
tex.Apply();
|
||||
return tex;
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
if (m_TopTex == null)
|
||||
{
|
||||
m_TopTex = Solid(OverlayTopGray);
|
||||
m_BottomTex = Solid(OverlayBottomGray);
|
||||
}
|
||||
|
||||
GUI.depth = -1000; // draw on top of any other IMGUI
|
||||
var half = Screen.height / 2f;
|
||||
GUI.DrawTexture(new Rect(0, 0, Screen.width, half), m_TopTex);
|
||||
GUI.DrawTexture(new Rect(0, half, Screen.width, Screen.height - half), m_BottomTex);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (m_TopTex != null) DestroyImmediate(m_TopTex);
|
||||
if (m_BottomTex != null) DestroyImmediate(m_BottomTex);
|
||||
}
|
||||
}
|
||||
|
||||
[UnityTearDown]
|
||||
public IEnumerator ExitPlayModeIfNeeded()
|
||||
{
|
||||
// A failed assertion inside the play-mode test would otherwise leave the editor playing.
|
||||
if (EditorApplication.isPlaying)
|
||||
yield return new ExitPlayMode();
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator CaptureGameView_ScreenSource_CapturesOverlayUi()
|
||||
{
|
||||
// Overlay/screen capture reads the presented game-view backbuffer, which requires a real,
|
||||
// rendering game view. In pure headless/batchmode there is none (and WaitForEndOfFrame never
|
||||
// fires), so self-ignore early. Edit-Mode + unknown-source guards cover CI regardless.
|
||||
if (IsHeadless || Application.isBatchMode)
|
||||
{
|
||||
Assert.Ignore("Screen capture requires an interactive game view (skipped headless/batchmode).");
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return new EnterPlayMode();
|
||||
|
||||
// Tolerate unrelated error logs from whatever project hosts the run (this test only checks pixels).
|
||||
LogAssert.ignoreFailingMessages = true;
|
||||
|
||||
// A camera that clears to blue and renders nothing, so a camera capture is overlay-free.
|
||||
var camGo = new GameObject("CLI199_ScreenCam");
|
||||
var cam = camGo.AddComponent<Camera>();
|
||||
cam.clearFlags = CameraClearFlags.SolidColor;
|
||||
cam.backgroundColor = Color.blue;
|
||||
cam.cullingMask = 0;
|
||||
|
||||
var overlayGo = new GameObject("CLI199_Overlay");
|
||||
overlayGo.AddComponent<FullScreenOverlay>();
|
||||
|
||||
// Poll a few frames: the overlay only appears in the capture once the game view has
|
||||
// repainted it into the backbuffer (a single frame is racy in the automated harness).
|
||||
CaptureResult screen = null;
|
||||
var overlayObserved = false;
|
||||
var stats = default((float fraction, float topLuma, float bottomLuma));
|
||||
for (var frame = 0; frame < 30 && !overlayObserved; frame++)
|
||||
{
|
||||
yield return new WaitForEndOfFrame();
|
||||
screen = CaptureCommands.CaptureGameView(64, 64, source: "screen");
|
||||
stats = GrayStats(screen.Base64);
|
||||
// The full-screen overlay should dominate the frame with neutral gray of ANY
|
||||
// brightness — brightness-agnostic on purpose, so a tone or orientation bug is a
|
||||
// failed assert below rather than an "overlay not observed" ignore.
|
||||
overlayObserved = stats.fraction > 0.8f;
|
||||
}
|
||||
|
||||
// Reliable everywhere the screen path runs: it returns a correctly-sized PNG tagged "screen".
|
||||
Assert.IsNotNull(screen, "screen capture should have run");
|
||||
Assert.AreEqual("screen", screen.Source);
|
||||
Assert.IsNotEmpty(screen.Base64, "screen capture should return an image");
|
||||
Assert.AreEqual(64, screen.Width);
|
||||
|
||||
// Pixel-presence needs the environment to actually present the overlay into the backbuffer.
|
||||
// Some CI/windowless setups render a blank/stale backbuffer even in Play Mode; treat an
|
||||
// unobservable overlay as inconclusive rather than a failure (verified live in the PR).
|
||||
if (!overlayObserved)
|
||||
{
|
||||
UnityEngine.Object.Destroy(overlayGo);
|
||||
UnityEngine.Object.Destroy(camGo);
|
||||
// ExitPlayModeIfNeeded ([UnityTearDown]) restores edit mode after this ignore.
|
||||
Assert.Ignore("Overlay not observable in this environment's game-view capture; screen path still verified.");
|
||||
}
|
||||
|
||||
// Tone round-trip: the authored grays must read back near their values. The old AsTexture
|
||||
// path double-encoded gamma in Linear projects (128 washed out to ~188). Orientation: the
|
||||
// light half must be on top — CaptureScreenshotIntoRenderTexture returns GPU-UV-oriented
|
||||
// content that the command must un-flip on D3D/Metal/Vulkan. (AUTHAPI-10 review)
|
||||
Assert.That(stats.topLuma, Is.EqualTo((float)OverlayTopGray.r).Within(20f),
|
||||
"top half should be the light gray at its authored tone (flip and/or gamma bug otherwise)");
|
||||
Assert.That(stats.bottomLuma, Is.EqualTo((float)OverlayBottomGray.r).Within(20f),
|
||||
"bottom half should be the dark gray at its authored tone (flip and/or gamma bug otherwise)");
|
||||
|
||||
// Overlay is observable here: prove it shows in a screen capture but NOT a camera capture
|
||||
// (the camera clears to solid blue and renders nothing, so it contains no neutral gray).
|
||||
var cameraShot = CaptureCommands.CaptureGameView(64, 64, camera: "CLI199_ScreenCam", source: "camera");
|
||||
Assert.Less(GrayStats(cameraShot.Base64).fraction, 0.2f, "overlay UI must be absent from a camera capture");
|
||||
|
||||
UnityEngine.Object.Destroy(overlayGo);
|
||||
UnityEngine.Object.Destroy(camGo);
|
||||
|
||||
yield return new ExitPlayMode();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decode the PNG and measure its neutral-gray content: the fraction of pixels that are
|
||||
/// near-neutral (r≈g≈b, away from black/white clip), plus the mean luminance of the image's
|
||||
/// top and bottom halves. Brightness-agnostic so gamma distortion shows up in the luminance
|
||||
/// values (not as a hidden overlay), and per-half so a vertical flip swaps them detectably.
|
||||
/// </summary>
|
||||
private static (float fraction, float topLuma, float bottomLuma) GrayStats(string base64)
|
||||
{
|
||||
var tex = new Texture2D(2, 2);
|
||||
try
|
||||
{
|
||||
Assert.IsTrue(tex.LoadImage(Convert.FromBase64String(base64)), "capture PNG should decode");
|
||||
var width = tex.width;
|
||||
var height = tex.height;
|
||||
var pixels = tex.GetPixels32();
|
||||
var gray = 0;
|
||||
long topSum = 0, bottomSum = 0;
|
||||
int topCount = 0, bottomCount = 0;
|
||||
for (var y = 0; y < height; y++)
|
||||
{
|
||||
for (var x = 0; x < width; x++)
|
||||
{
|
||||
var p = pixels[y * width + x];
|
||||
if (Mathf.Abs(p.r - p.g) <= 12 && Mathf.Abs(p.g - p.b) <= 12 && p.r >= 40 && p.r <= 240)
|
||||
gray++;
|
||||
|
||||
// GetPixels32 row 0 is the image's BOTTOM row.
|
||||
var luma = (p.r + p.g + p.b) / 3;
|
||||
if (y >= height / 2) { topSum += luma; topCount++; }
|
||||
else { bottomSum += luma; bottomCount++; }
|
||||
}
|
||||
}
|
||||
|
||||
return (pixels.Length == 0 ? 0f : (float)gray / pixels.Length,
|
||||
topCount == 0 ? 0f : (float)topSum / topCount,
|
||||
bottomCount == 0 ? 0f : (float)bottomSum / bottomCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
UnityEngine.Object.DestroyImmediate(tex);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
+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>
|
||||
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:
|
||||
@@ -0,0 +1,341 @@
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using Unity.Pipeline.Models;
|
||||
using Unity.Pipeline.Runtime.Commands;
|
||||
using Unity.Pipeline.Telemetry;
|
||||
using Unity.Pipeline.Tests;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the eval command (CodeEvalCommand), exercised directly and via PipelineClient.
|
||||
/// Compiler-level behavior (EvalCodeCompiler) is covered by EvalCodeCompilerTests.
|
||||
/// </summary>
|
||||
class CodeEvalCommandTests
|
||||
{
|
||||
// Eval now writes eval-usage telemetry (AUTHAPI-29). Redirect it to a throwaway temp directory
|
||||
// so these tests never append to the real project's Library/Pipeline/eval-usage.jsonl, and
|
||||
// restore the mutated statics afterwards.
|
||||
private string m_TelemetryDir;
|
||||
private string m_SavedOverrideDir;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
m_TelemetryDir = Path.Combine(Path.GetTempPath(), "CodeEvalTelemetry_" + Guid.NewGuid().ToString("N")[..8]);
|
||||
m_SavedOverrideDir = EvalUsageTelemetry.OverrideDirectory;
|
||||
EvalUsageTelemetry.OverrideDirectory = m_TelemetryDir;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
// Recording is fire-and-forget; drain in-flight background records before the redirect
|
||||
// target is deleted (a late append would otherwise recreate the temp directory).
|
||||
EvalUsageTelemetry.WaitForPendingRecords();
|
||||
|
||||
EvalUsageTelemetry.OverrideDirectory = m_SavedOverrideDir;
|
||||
if (Directory.Exists(m_TelemetryDir))
|
||||
{
|
||||
try { Directory.Delete(m_TelemetryDir, true); }
|
||||
catch { /* ignore cleanup failures */ }
|
||||
}
|
||||
}
|
||||
|
||||
#region Direct
|
||||
|
||||
[Test]
|
||||
public void EvaluateCode_SimpleArithmetic_ReturnsResult()
|
||||
{
|
||||
var r = CodeEvalCommand.EvaluateCode("return 2 + 2;");
|
||||
Assert.IsTrue(r.Success, r.Error);
|
||||
Assert.AreEqual(4, r.Result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EvaluateCode_StringExpression_ReturnsString()
|
||||
{
|
||||
var r = CodeEvalCommand.EvaluateCode("return \"Hello World\";");
|
||||
Assert.IsTrue(r.Success, r.Error);
|
||||
Assert.AreEqual("Hello World", r.Result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EvaluateCode_UnityApi_ReturnsVersion()
|
||||
{
|
||||
var r = CodeEvalCommand.EvaluateCode("return Application.unityVersion;");
|
||||
Assert.IsTrue(r.Success, r.Error);
|
||||
Assert.IsInstanceOf<string>(r.Result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EvaluateCode_DebugLog_ReturnsExplicitValue()
|
||||
{
|
||||
var r = CodeEvalCommand.EvaluateCode("Debug.Log(\"x\"); return \"logged\";");
|
||||
Assert.IsTrue(r.Success, r.Error);
|
||||
Assert.AreEqual("logged", r.Result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EvaluateCode_EmptyCode_BadRequest()
|
||||
{
|
||||
var r = CodeEvalCommand.EvaluateCode("");
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.AreEqual("Bad Request", r.Error);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EvaluateCode_NullCode_BadRequest()
|
||||
{
|
||||
var r = CodeEvalCommand.EvaluateCode(null);
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.AreEqual("Bad Request", r.Error);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EvaluateCode_SyntaxError_CompilationFailed()
|
||||
{
|
||||
var r = CodeEvalCommand.EvaluateCode("return 2 +;");
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.AreEqual("Compilation Failed", r.Error);
|
||||
Assert.Greater(r.Diagnostics.Count, 0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EvaluateCode_RuntimeException_Fails()
|
||||
{
|
||||
var r = CodeEvalCommand.EvaluateCode("throw new System.Exception(\"boom\");");
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.IsNotNull(r.ErrorDetails);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EvaluateCode_ZeroTimeout_BadRequest()
|
||||
{
|
||||
var r = CodeEvalCommand.EvaluateCode("return 1;", timeout: 0);
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.AreEqual("Bad Request", r.Error);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EvaluateCode_ExcessiveTimeout_BadRequest()
|
||||
{
|
||||
// Cap raised to 24h (CLI-335): 40s is legal now; beyond the cap still rejects.
|
||||
var r = CodeEvalCommand.EvaluateCode("return 1;", timeout: 86_400_001);
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.AreEqual("Bad Request", r.Error);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EvaluateCode_LongRunningTimeoutRequest_ShouldNotBeRejectedOutright()
|
||||
{
|
||||
// Repro for the reported bug (a legitimately slow eval needed a timeout above 30s):
|
||||
// EvaluateSource's hardcoded ceiling rejects any `timeout` above 30000ms outright,
|
||||
// before even attempting compilation, so a caller can't even ask for more time
|
||||
// regardless of whether their code would actually need it.
|
||||
var r = CodeEvalCommand.EvaluateCode("return 1;", timeout: 35000);
|
||||
Assert.IsTrue(r.Success, $"Expected a timeout request above 30000ms to be accepted, got: {r.Error} / {r.ErrorDetails}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EvaluateCode_RecordsExecutionTime()
|
||||
{
|
||||
var r = CodeEvalCommand.EvaluateCode("return 42;", timeout: 5000);
|
||||
Assert.IsTrue(r.Success, r.Error);
|
||||
Assert.Greater(r.ExecutionTimeMs, 0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EvaluateCode_Success_PopulatesEnvelopeMetadata()
|
||||
{
|
||||
var before = System.DateTime.UtcNow;
|
||||
var r = CodeEvalCommand.EvaluateCode("return 42;");
|
||||
Assert.IsTrue(r.Success, r.Error);
|
||||
Assert.AreEqual("eval", r.Command);
|
||||
Assert.AreNotEqual(default(System.DateTime), r.ExecutedAt, "executedAt must be a real timestamp");
|
||||
Assert.GreaterOrEqual(r.ExecutedAt, before);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EvaluateCode_Failure_PopulatesEnvelopeMetadata()
|
||||
{
|
||||
var before = System.DateTime.UtcNow;
|
||||
var r = CodeEvalCommand.EvaluateCode("return 2 +;");
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.AreEqual("eval", r.Command);
|
||||
Assert.AreNotEqual(default(System.DateTime), r.ExecutedAt, "executedAt must be a real timestamp");
|
||||
Assert.GreaterOrEqual(r.ExecutedAt, before);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EvalFile
|
||||
|
||||
[Test]
|
||||
public void EvaluateFile_FromFile_ReturnsResult()
|
||||
{
|
||||
var path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
"eval_test_" + System.Guid.NewGuid().ToString("N") + ".cs");
|
||||
System.IO.File.WriteAllText(path, "return 2 + 2;");
|
||||
try
|
||||
{
|
||||
var r = CodeEvalCommand.EvaluateFile(path);
|
||||
Assert.IsTrue(r.Success, r.Error);
|
||||
Assert.AreEqual(4, r.Result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.IO.File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EvaluateFile_FileNotFound_BadRequest()
|
||||
{
|
||||
var path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
"eval_missing_" + System.Guid.NewGuid().ToString("N") + ".cs");
|
||||
var r = CodeEvalCommand.EvaluateFile(path);
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.AreEqual("Bad Request", r.Error);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EvaluateFile_NonCsFile_BadRequest()
|
||||
{
|
||||
var path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
"eval_test_" + System.Guid.NewGuid().ToString("N") + ".txt");
|
||||
System.IO.File.WriteAllText(path, "return 2 + 2;");
|
||||
try
|
||||
{
|
||||
var r = CodeEvalCommand.EvaluateFile(path);
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.AreEqual("Bad Request", r.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.IO.File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EvaluateFile_EmptyFile_BadRequest()
|
||||
{
|
||||
var path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
"eval_test_" + System.Guid.NewGuid().ToString("N") + ".cs");
|
||||
System.IO.File.WriteAllText(path, " ");
|
||||
try
|
||||
{
|
||||
var r = CodeEvalCommand.EvaluateFile(path);
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.AreEqual("Bad Request", r.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.IO.File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EvaluateFile_NullFile_BadRequest()
|
||||
{
|
||||
var r = CodeEvalCommand.EvaluateFile(null);
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.AreEqual("Bad Request", r.Error);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ViaClient
|
||||
|
||||
[Test]
|
||||
public void Eval_ViaClient_ReturnsResult()
|
||||
{
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
// The test client authenticates with the server's bearer token.
|
||||
var response = server.Execute("eval", new { code = "return Application.platform.ToString();", timeout = 5000 });
|
||||
Assert.IsTrue(response.IsSuccess, response.Error);
|
||||
|
||||
var r = response.GetTypedResponse<EvalResponse>();
|
||||
Assert.IsNotNull(r, "Should deserialize an EvalResponse");
|
||||
Assert.IsTrue(r.Success, r.Error);
|
||||
Assert.IsInstanceOf<string>(r.Result);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Eval_ViaClient_SyntaxError_CompilationFailed()
|
||||
{
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("eval", new { code = "return 2 +;", timeout = 5000 });
|
||||
|
||||
var r = response.GetTypedResponse<EvalResponse>();
|
||||
Assert.IsNotNull(r, "Should deserialize an EvalResponse");
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.AreEqual("Compilation Failed", r.Error);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Eval_ViaClient_RequestedTimeoutBelowDispatcherDefault_TimesOutEarly()
|
||||
{
|
||||
// Repro for the dispatcher half of the reported bug: the Dispatcher.Invoke wrapping every
|
||||
// MainThreadRequired command (including eval) uses a hardcoded 60000ms wait, ignoring
|
||||
// whatever `timeout` the caller actually asked for. So a caller who asks for far less than
|
||||
// 60s currently just waits for the code to finish anyway; only the eval's own `timeout`
|
||||
// value, once threaded through, should be the deadline that governs the wait.
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
LogAssert.Expect(LogType.Error, new Regex("Failed to handle /api/exec request.*timed out after 150ms"));
|
||||
|
||||
// PipelineTestServer.Execute's own pump loop dequeues and runs the eval work item
|
||||
// synchronously once it starts, so it can't observe the request's completion until
|
||||
// that (compile + 600ms sleep) finishes — give it enough headroom for that, on top of
|
||||
// the compile itself, even though the actual dispatcher timeout below fires in ~150ms.
|
||||
var response = server.Execute("eval",
|
||||
new { code = "System.Threading.Thread.Sleep(600); return 1;", timeout = 150 },
|
||||
timeoutMs: 20000);
|
||||
|
||||
Assert.IsFalse(response.IsSuccess,
|
||||
$"Expected the 150ms request to time out instead of waiting for the 600ms sleep to finish: {response.RawResponse}");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EvalFile_ViaClient_ReturnsResult()
|
||||
{
|
||||
var path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
"eval_test_" + System.Guid.NewGuid().ToString("N") + ".cs");
|
||||
System.IO.File.WriteAllText(path, "return 6 * 7;");
|
||||
try
|
||||
{
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("eval_file", new { file = path, timeout = 5000 });
|
||||
Assert.IsTrue(response.IsSuccess, response.Error);
|
||||
|
||||
var r = response.GetTypedResponse<EvalResponse>();
|
||||
Assert.IsNotNull(r, "Should deserialize an EvalResponse");
|
||||
Assert.IsTrue(r.Success, r.Error);
|
||||
Assert.AreEqual(42, r.Result);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.IO.File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0919730cfeec19e40b46ecce9479cf70
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
using System.IO;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Compilation;
|
||||
using Unity.Pipeline.CodeReload;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the assemblyDir parameter of code reload cleanup.
|
||||
/// Verifies that cleanup operates on the specified directory rather than the default temp directory.
|
||||
/// </summary>
|
||||
class CodeReloadAssemblyDirectoryTests
|
||||
{
|
||||
private string testAssemblyDir;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
testAssemblyDir = Path.Combine("TestAssemblies", "CodeReload");
|
||||
Directory.CreateDirectory(testAssemblyDir);
|
||||
|
||||
// Clear state
|
||||
CodeReloadRegistry.ClearAllForTesting();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
// Clean up test files and directories
|
||||
if (Directory.Exists(testAssemblyDir))
|
||||
{
|
||||
Directory.Delete(testAssemblyDir, true);
|
||||
}
|
||||
if (Directory.Exists("TestAssemblies"))
|
||||
{
|
||||
Directory.Delete("TestAssemblies", true);
|
||||
}
|
||||
|
||||
// Clean up code reload state
|
||||
CodeReloadRegistry.ClearAllForTesting();
|
||||
CodeReloadCompiler.CleanupCodeReloadDlls(); // Default cleanup
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CleanupCodeReloadDlls_WithAssemblyDir_CleansUpDiskFiles()
|
||||
{
|
||||
// Arrange - Create some test assembly files
|
||||
var testAssembly1 = Path.Combine(testAssemblyDir, "TestAssembly_001.dll");
|
||||
var testAssembly2 = Path.Combine(testAssemblyDir, "TestAssembly_002.dll");
|
||||
var testAssembly3 = Path.Combine(testAssemblyDir, "TestAssembly_003.dll");
|
||||
|
||||
File.WriteAllText(testAssembly1, "fake dll content 1");
|
||||
File.WriteAllText(testAssembly2, "fake dll content 2");
|
||||
File.WriteAllText(testAssembly3, "fake dll content 3");
|
||||
|
||||
// Verify files exist before cleanup
|
||||
Assert.IsTrue(File.Exists(testAssembly1), "Test file 1 should exist before cleanup");
|
||||
Assert.IsTrue(File.Exists(testAssembly2), "Test file 2 should exist before cleanup");
|
||||
Assert.IsTrue(File.Exists(testAssembly3), "Test file 3 should exist before cleanup");
|
||||
|
||||
// Act - Cleanup the specified directory
|
||||
var result = CodeReloadCompiler.CleanupCodeReloadDlls(testAssemblyDir);
|
||||
|
||||
// Assert - Cleanup should succeed and keep only the latest version
|
||||
Assert.IsTrue(result.Success, $"Cleanup should succeed. Message: {result.Message}");
|
||||
Assert.AreEqual(2, result.DeletedFiles.Count, "Should delete the two older versions");
|
||||
Assert.IsFalse(File.Exists(testAssembly1), "Version 001 should be deleted");
|
||||
Assert.IsFalse(File.Exists(testAssembly2), "Version 002 should be deleted");
|
||||
Assert.IsTrue(File.Exists(testAssembly3), "Latest version should be kept");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CleanupCodeReloadDlls_WithMissingAssemblyDir_SucceedsWithoutDeleting()
|
||||
{
|
||||
// Arrange
|
||||
var missingDir = Path.Combine(testAssemblyDir, "DoesNotExist");
|
||||
Assert.IsFalse(Directory.Exists(missingDir), "Precondition: directory must not exist");
|
||||
|
||||
// Act
|
||||
var result = CodeReloadCompiler.CleanupCodeReloadDlls(missingDir);
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(result.Success, $"Cleanup should succeed for a missing directory. Message: {result.Message}");
|
||||
Assert.AreEqual(0, result.DeletedFiles.Count, "Should not delete any files");
|
||||
Assert.That(result.Message, Does.Contain("No code reload directory"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7e24a4fd1f3d4c04e93baa986056cfbc
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Compilation;
|
||||
using Unity.Pipeline.CodeReload;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests specifically for CodeReloadMethod attribute compilation and reflection.
|
||||
/// Helps diagnose issues with attribute detection in compiled assemblies.
|
||||
/// </summary>
|
||||
class CodeReloadAttributeTests
|
||||
{
|
||||
[Test]
|
||||
public void CodeReloadOverrideMethodAttribute_DirectUsage_CanBeReflected()
|
||||
{
|
||||
// Test that the attribute can be found via reflection on a directly compiled class
|
||||
|
||||
var method = typeof(TestCodeReloadClass).GetMethod("TestOverrideMethod");
|
||||
Assert.IsNotNull(method, "Test method should exist");
|
||||
|
||||
var attribute = method.GetCustomAttribute<CodeReloadOverrideMethodAttribute>();
|
||||
Assert.IsNotNull(attribute, "CodeReloadMethod attribute should be found");
|
||||
Assert.AreEqual("TestTarget.TestMethod", attribute.TargetMethodId, "Target method ID should match");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RoslynCompilationService_WithCodeReloadAttribute_CompilesAndReflects()
|
||||
{
|
||||
// Test that RoslynCompilationService can compile code with CodeReloadMethod attribute
|
||||
// and the attribute can be reflected from the compiled assembly
|
||||
|
||||
var sourceCode = @"
|
||||
using Unity.Pipeline.CodeReload;
|
||||
using UnityEngine;
|
||||
|
||||
public static class CompiledCodeReloadClass
|
||||
{
|
||||
[CodeReloadOverrideMethod(""CompiledTarget.CompiledMethod"")]
|
||||
public static void CompiledOverrideMethod(object instance)
|
||||
{
|
||||
Debug.Log(""Compiled code reload method"");
|
||||
}
|
||||
}";
|
||||
|
||||
var request = new CompilationRequest
|
||||
{
|
||||
SourceCode = sourceCode,
|
||||
AssemblyName = "CodeReloadAttributeTest",
|
||||
AdditionalAssemblyPrefixes = new[] { "Unity.Pipeline" }
|
||||
};
|
||||
|
||||
var result = RoslynCompilationService.Compile(request);
|
||||
|
||||
Assert.IsTrue(result.Success, $"Compilation should succeed. Errors: {string.Join(", ", result.Diagnostics?.Select(d => d.Message) ?? new string[0])}");
|
||||
Assert.IsNotNull(result.Assembly, "Should return compiled assembly");
|
||||
|
||||
// Find the compiled class and method
|
||||
var compiledType = result.Assembly.GetType("CompiledCodeReloadClass");
|
||||
Assert.IsNotNull(compiledType, "Compiled class should exist");
|
||||
|
||||
var compiledMethod = compiledType.GetMethod("CompiledOverrideMethod");
|
||||
Assert.IsNotNull(compiledMethod, "Compiled method should exist");
|
||||
|
||||
// Check if attribute can be found via reflection
|
||||
var customAttributes = compiledMethod.GetCustomAttributes(true);
|
||||
|
||||
var codeReloadAttribute = compiledMethod.GetCustomAttribute<CodeReloadOverrideMethodAttribute>();
|
||||
if (codeReloadAttribute == null)
|
||||
{
|
||||
// Try to find by name
|
||||
var attributeByName = customAttributes.FirstOrDefault(a => a.GetType().Name == "CodeReloadOverrideMethodAttribute");
|
||||
|
||||
if (attributeByName != null)
|
||||
{
|
||||
codeReloadAttribute = attributeByName as CodeReloadOverrideMethodAttribute;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.IsNotNull(codeReloadAttribute, "CodeReloadMethod attribute should be found on compiled method");
|
||||
Assert.AreEqual("CompiledTarget.CompiledMethod", codeReloadAttribute.TargetMethodId, "Target method ID should match");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MetadataReferences_IncludeUnityPipeline_CanFindCodeReloadTypes()
|
||||
{
|
||||
// Verify that Unity.Pipeline assemblies are included in metadata references
|
||||
|
||||
var references = RoslynCompilationService.GetMetadataReferences(new[] { "Unity.Pipeline" });
|
||||
|
||||
var pipelineReferences = references.Where(r => r.Display?.Contains("Unity.Pipeline") == true).ToList();
|
||||
|
||||
Assert.Greater(pipelineReferences.Count, 0, "Should find Unity.Pipeline assembly references");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test class with CodeReloadMethod attribute for direct testing.
|
||||
/// </summary>
|
||||
public static class TestCodeReloadClass
|
||||
{
|
||||
[CodeReloadOverrideMethod("TestTarget.TestMethod")]
|
||||
public static void TestOverrideMethod(object instance)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1b46f0c74679e854288a8933221a317a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,316 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.CodeReload;
|
||||
using Unity.Pipeline.Runtime.Commands;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Probe for the baseline-aware in-place reload tests: two independent [CodeReload] methods so
|
||||
/// a reload of one can be asserted to leave the other running compiled.
|
||||
/// </summary>
|
||||
public class BaselineReloadProbe
|
||||
{
|
||||
public int first;
|
||||
public int second;
|
||||
|
||||
[CodeReload]
|
||||
public void First()
|
||||
{
|
||||
first = 1; // original body
|
||||
}
|
||||
|
||||
[CodeReload]
|
||||
public void Second()
|
||||
{
|
||||
second = 1; // original body
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="CodeReloadBaseline"/> — the per-method snapshot that lets a reload skip
|
||||
/// methods still matching the compiled code — and for its use by InPlaceReloadProcessor
|
||||
/// (skip unchanged, unregister reverted).
|
||||
/// </summary>
|
||||
class CodeReloadBaselineTests
|
||||
{
|
||||
private const string FakePath = "Assets/FakeBaselineProbe.cs";
|
||||
|
||||
private const string BaselineSource = @"
|
||||
using Unity.Pipeline.CodeReload;
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
public class BaselineReloadProbe
|
||||
{
|
||||
public int first;
|
||||
public int second;
|
||||
|
||||
[CodeReload]
|
||||
public void First()
|
||||
{
|
||||
first = 1; // original body
|
||||
}
|
||||
|
||||
[CodeReload]
|
||||
public void Second()
|
||||
{
|
||||
second = 1; // original body
|
||||
}
|
||||
}
|
||||
}";
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
CodeReloadBaseline.Clear();
|
||||
CodeReloadRegistry.UnregisterMethodOverride("BaselineReloadProbe.First");
|
||||
CodeReloadRegistry.UnregisterMethodOverride("BaselineReloadProbe.Second");
|
||||
}
|
||||
|
||||
// -------- classification --------
|
||||
|
||||
[Test]
|
||||
public void NoBaseline_ClassifiesNothing()
|
||||
{
|
||||
Assert.IsNull(CodeReloadBaseline.GetUnchangedMethods(FakePath, BaselineSource),
|
||||
"Without a captured baseline every method must be treated as changed.");
|
||||
Assert.IsFalse(CodeReloadBaseline.IsFileUpToDate(FakePath, BaselineSource));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IdenticalSource_AllMethodsUnchanged()
|
||||
{
|
||||
Assert.IsTrue(CodeReloadBaseline.CaptureFromSource(FakePath, BaselineSource));
|
||||
|
||||
var unchanged = CodeReloadBaseline.GetUnchangedMethods(FakePath, BaselineSource);
|
||||
Assert.IsNotNull(unchanged);
|
||||
CollectionAssert.AreEquivalent(new[] { "First", "Second" }, unchanged);
|
||||
Assert.IsTrue(CodeReloadBaseline.IsFileUpToDate(FakePath, BaselineSource));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhitespaceAndCommentEdits_DoNotCountAsChanges()
|
||||
{
|
||||
CodeReloadBaseline.CaptureFromSource(FakePath, BaselineSource);
|
||||
|
||||
var reformatted = BaselineSource
|
||||
.Replace("first = 1; // original body", "first =\n 1; /* reflowed, comment changed */")
|
||||
.Replace("public void Second()", "public void Second()");
|
||||
|
||||
var unchanged = CodeReloadBaseline.GetUnchangedMethods(FakePath, reformatted);
|
||||
CollectionAssert.AreEquivalent(new[] { "First", "Second" }, unchanged);
|
||||
Assert.IsTrue(CodeReloadBaseline.IsFileUpToDate(FakePath, reformatted));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BodyEdit_MarksOnlyThatMethodChanged()
|
||||
{
|
||||
CodeReloadBaseline.CaptureFromSource(FakePath, BaselineSource);
|
||||
|
||||
var edited = BaselineSource.Replace("second = 1;", "second = 99;");
|
||||
var unchanged = CodeReloadBaseline.GetUnchangedMethods(FakePath, edited);
|
||||
|
||||
CollectionAssert.AreEquivalent(new[] { "First" }, unchanged);
|
||||
Assert.IsFalse(CodeReloadBaseline.IsFileUpToDate(FakePath, edited));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SignatureEdit_MarksMethodChanged()
|
||||
{
|
||||
CodeReloadBaseline.CaptureFromSource(FakePath, BaselineSource);
|
||||
|
||||
// Renaming a parameterless method's parameter list is not possible; add a default arg.
|
||||
var edited = BaselineSource.Replace("public void Second()", "public void Second(int times = 1)");
|
||||
var unchanged = CodeReloadBaseline.GetUnchangedMethods(FakePath, edited);
|
||||
|
||||
// The signature change also changes the class context (the signature is context), so
|
||||
// conservatively nothing is skippable.
|
||||
CollectionAssert.DoesNotContain(unchanged, "Second");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FieldEdit_ChangesContext_NothingSkippable()
|
||||
{
|
||||
CodeReloadBaseline.CaptureFromSource(FakePath, BaselineSource);
|
||||
|
||||
// An untouched body can read this field; the override compiles against the current
|
||||
// file, so a declaration change conservatively invalidates every method.
|
||||
var edited = BaselineSource.Replace("public int second;", "public int second = 5;");
|
||||
var unchanged = CodeReloadBaseline.GetUnchangedMethods(FakePath, edited);
|
||||
|
||||
Assert.IsNotNull(unchanged);
|
||||
Assert.AreEqual(0, unchanged.Count);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HelperBodyEdit_DoesNotChangeContext()
|
||||
{
|
||||
var withHelper = BaselineSource.Replace(
|
||||
"public int first;",
|
||||
"public int first;\n private int Helper() { return 3; }\n private int Arrow => 4;");
|
||||
CodeReloadBaseline.CaptureFromSource(FakePath, withHelper);
|
||||
|
||||
// Overrides call helpers through the compiled host either way, so a helper body edit
|
||||
// can't change what an override does — it must not mark reloadable methods changed.
|
||||
var edited = withHelper
|
||||
.Replace("return 3;", "return 30;")
|
||||
.Replace("Arrow => 4;", "Arrow => 40;");
|
||||
var unchanged = CodeReloadBaseline.GetUnchangedMethods(FakePath, edited);
|
||||
|
||||
CollectionAssert.AreEquivalent(new[] { "First", "Second" }, unchanged);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MustReloadMethods_BlockUpToDateShortCircuit()
|
||||
{
|
||||
CodeReloadBaseline.CaptureFromSource(FakePath, BaselineSource);
|
||||
|
||||
// A device already running an override for Second can't unregister it remotely — the
|
||||
// file must not be reported up to date even though the source matches the baseline.
|
||||
var pushed = new HashSet<string> { "Second" };
|
||||
Assert.IsFalse(CodeReloadBaseline.IsFileUpToDate(FakePath, BaselineSource, null, pushed));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SerializeRestore_RoundTripsClassification()
|
||||
{
|
||||
CodeReloadBaseline.CaptureFromSource(FakePath, BaselineSource);
|
||||
var serialized = CodeReloadBaseline.Serialize();
|
||||
|
||||
CodeReloadBaseline.Clear();
|
||||
Assert.IsNull(CodeReloadBaseline.GetUnchangedMethods(FakePath, BaselineSource));
|
||||
|
||||
CodeReloadBaseline.Restore(serialized);
|
||||
var edited = BaselineSource.Replace("second = 1;", "second = 99;");
|
||||
CollectionAssert.AreEquivalent(new[] { "First" },
|
||||
CodeReloadBaseline.GetUnchangedMethods(FakePath, edited));
|
||||
}
|
||||
|
||||
// -------- processor integration --------
|
||||
|
||||
[Test]
|
||||
public void Reload_SkipsUnchangedMethods_AndUnregistersReverted()
|
||||
{
|
||||
CodeReloadRegistry.RegisterReloadableMethod(
|
||||
typeof(BaselineReloadProbe).GetMethod(nameof(BaselineReloadProbe.First)),
|
||||
new CodeReloadAttribute());
|
||||
CodeReloadRegistry.RegisterReloadableMethod(
|
||||
typeof(BaselineReloadProbe).GetMethod(nameof(BaselineReloadProbe.Second)),
|
||||
new CodeReloadAttribute());
|
||||
|
||||
var path = Path.Combine(Application.temporaryCachePath, "BaselineReloadProbe_edit.cs");
|
||||
try
|
||||
{
|
||||
// Watch start: disk matches the compiled probe.
|
||||
CodeReloadBaseline.CaptureFromSource(path, BaselineSource);
|
||||
|
||||
// Save 1: untouched file → nothing compiles, nothing registers.
|
||||
File.WriteAllText(path, BaselineSource);
|
||||
var result = InPlaceReloadProcessor.ProcessSourceFileOnMainThread(path);
|
||||
Assert.IsTrue(result.Success, result.ErrorMessage);
|
||||
Assert.IsTrue(result.AllUpToDate);
|
||||
CollectionAssert.IsEmpty(result.RegisteredMethods);
|
||||
CollectionAssert.AreEquivalent(new[] { "First", "Second" }, result.UpToDateMethods);
|
||||
Assert.IsFalse(CodeReloadRegistry.HasOverride("BaselineReloadProbe.Second"));
|
||||
|
||||
// Save 2: only Second edited → First stays compiled, Second gets an override.
|
||||
File.WriteAllText(path, BaselineSource.Replace("second = 1;", "second = 99;"));
|
||||
result = InPlaceReloadProcessor.ProcessSourceFileOnMainThread(path);
|
||||
Assert.IsTrue(result.Success, result.ErrorMessage);
|
||||
CollectionAssert.AreEquivalent(new[] { "BaselineReloadProbe.Second" }, result.RegisteredMethods);
|
||||
CollectionAssert.AreEquivalent(new[] { "First" }, result.UpToDateMethods);
|
||||
Assert.IsFalse(CodeReloadRegistry.HasOverride("BaselineReloadProbe.First"));
|
||||
|
||||
var probe = new BaselineReloadProbe();
|
||||
probe.Second();
|
||||
Assert.AreEqual(99, probe.second, "Edited body should run via the woven dispatch");
|
||||
probe.First();
|
||||
Assert.AreEqual(1, probe.first, "Unchanged body should run compiled");
|
||||
|
||||
// Save 3: the edit is undone → the stale override is removed, compiled body runs.
|
||||
File.WriteAllText(path, BaselineSource);
|
||||
result = InPlaceReloadProcessor.ProcessSourceFileOnMainThread(path);
|
||||
Assert.IsTrue(result.Success, result.ErrorMessage);
|
||||
Assert.IsTrue(result.AllUpToDate);
|
||||
CollectionAssert.AreEquivalent(new[] { "BaselineReloadProbe.Second" }, result.RevertedMethods);
|
||||
Assert.IsFalse(CodeReloadRegistry.HasOverride("BaselineReloadProbe.Second"));
|
||||
|
||||
var reverted = new BaselineReloadProbe();
|
||||
reverted.Second();
|
||||
Assert.AreEqual(1, reverted.second, "Reverted method should run the original compiled body");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Test] // The reload_file command's contract for the baseline paths: an up-to-date file is
|
||||
// an explicit "up to date" success (never "successful with 0 methods"), and stale
|
||||
// overrides it reverts are reported, not silently unregistered.
|
||||
public void ReloadFileCommand_UpToDate_ReportsUpToDateAndReverted()
|
||||
{
|
||||
CodeReloadRegistry.RegisterReloadableMethod(
|
||||
typeof(BaselineReloadProbe).GetMethod(nameof(BaselineReloadProbe.First)),
|
||||
new CodeReloadAttribute());
|
||||
CodeReloadRegistry.RegisterReloadableMethod(
|
||||
typeof(BaselineReloadProbe).GetMethod(nameof(BaselineReloadProbe.Second)),
|
||||
new CodeReloadAttribute());
|
||||
|
||||
var path = Path.Combine(Application.temporaryCachePath, "BaselineReloadProbe_cmd.cs");
|
||||
try
|
||||
{
|
||||
CodeReloadBaseline.CaptureFromSource(path, BaselineSource);
|
||||
|
||||
// Edit Second → a normal reload that registers one override.
|
||||
File.WriteAllText(path, BaselineSource.Replace("second = 1;", "second = 99;"));
|
||||
var edited = CodeReloadCommands.ReloadFile(path);
|
||||
Assert.IsTrue(edited.Success, edited.Message);
|
||||
StringAssert.Contains("1 methods", edited.Message);
|
||||
|
||||
// Undo the edit → explicit up-to-date success that names the reverted override.
|
||||
File.WriteAllText(path, BaselineSource);
|
||||
var upToDate = CodeReloadCommands.ReloadFile(path);
|
||||
Assert.IsTrue(upToDate.Success, upToDate.Message);
|
||||
StringAssert.Contains("Up to date", upToDate.Message);
|
||||
StringAssert.Contains("reverted 1 stale override(s): BaselineReloadProbe.Second", upToDate.Message);
|
||||
StringAssert.DoesNotContain("0 methods", upToDate.Message);
|
||||
Assert.IsFalse(CodeReloadRegistry.HasOverride("BaselineReloadProbe.Second"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Reload_WithoutBaseline_ReloadsEverything()
|
||||
{
|
||||
CodeReloadRegistry.RegisterReloadableMethod(
|
||||
typeof(BaselineReloadProbe).GetMethod(nameof(BaselineReloadProbe.First)),
|
||||
new CodeReloadAttribute());
|
||||
CodeReloadRegistry.RegisterReloadableMethod(
|
||||
typeof(BaselineReloadProbe).GetMethod(nameof(BaselineReloadProbe.Second)),
|
||||
new CodeReloadAttribute());
|
||||
|
||||
var path = Path.Combine(Application.temporaryCachePath, "BaselineReloadProbe_nobaseline.cs");
|
||||
try
|
||||
{
|
||||
// No baseline captured (no watch): pre-baseline behavior — every method reloads.
|
||||
File.WriteAllText(path, BaselineSource);
|
||||
var result = InPlaceReloadProcessor.ProcessSourceFileOnMainThread(path);
|
||||
Assert.IsTrue(result.Success, result.ErrorMessage);
|
||||
Assert.IsFalse(result.AllUpToDate);
|
||||
CollectionAssert.AreEquivalent(
|
||||
new[] { "BaselineReloadProbe.First", "BaselineReloadProbe.Second" },
|
||||
result.RegisteredMethods);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4b97af406baa48a4955d8623a0ee0b57
|
||||
@@ -0,0 +1,177 @@
|
||||
using System.Collections;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.CodeReload;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Basic tests for code reload Pattern A method override functionality.
|
||||
/// Tests the end-to-end flow: mark method -> create override -> compile -> verify behavior.
|
||||
/// </summary>
|
||||
class CodeReloadBasicTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Test class with reloadable methods for testing.
|
||||
/// </summary>
|
||||
public class TestComponent : MonoBehaviour
|
||||
{
|
||||
public int lastCalculatedValue;
|
||||
public string lastMessage;
|
||||
|
||||
public void UpdateValue()
|
||||
{
|
||||
lastCalculatedValue = 10; // Original behavior
|
||||
lastMessage = "Original UpdateValue called";
|
||||
}
|
||||
|
||||
public int CalculateScore(int input)
|
||||
{
|
||||
return input * 2; // Original calculation
|
||||
}
|
||||
}
|
||||
|
||||
private TestComponent testComponent;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
// Create test component
|
||||
var go = new GameObject("CodeReloadTestObject");
|
||||
testComponent = go.AddComponent<TestComponent>();
|
||||
|
||||
// Clear any existing code reload state (including reloadable methods for test isolation)
|
||||
CodeReloadRegistry.ClearAllForTesting();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
if (testComponent != null && testComponent.gameObject != null)
|
||||
{
|
||||
Object.DestroyImmediate(testComponent.gameObject);
|
||||
}
|
||||
|
||||
// Clean up code reload state
|
||||
CodeReloadRegistry.ClearAllForTesting();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CodeReloadRegistry_RegisterReloadableMethod_Success()
|
||||
{
|
||||
// Arrange
|
||||
var method = typeof(TestComponent).GetMethod("UpdateValue");
|
||||
var attribute = new CodeReloadAttribute();
|
||||
|
||||
// Act
|
||||
CodeReloadRegistry.RegisterReloadableMethod(method, attribute);
|
||||
|
||||
// Assert
|
||||
var stats = CodeReloadRegistry.GetStats();
|
||||
Assert.That(stats.ReloadableMethodCount, Is.EqualTo(1));
|
||||
Assert.That(stats.ReloadableMethodIds.Contains("TestComponent.UpdateValue"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CodeReloadRegistry_RegisterReloadableMethodWithCustomId_Success()
|
||||
{
|
||||
// Arrange
|
||||
var method = typeof(TestComponent).GetMethod("CalculateScore");
|
||||
var attribute = new CodeReloadAttribute { Id = "CustomCalculation" };
|
||||
|
||||
// Act
|
||||
CodeReloadRegistry.RegisterReloadableMethod(method, attribute);
|
||||
|
||||
// Assert
|
||||
var stats = CodeReloadRegistry.GetStats();
|
||||
Assert.That(stats.ReloadableMethodCount, Is.EqualTo(1));
|
||||
Assert.That(stats.ReloadableMethodIds.Contains("CustomCalculation"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CodeReloadRegistry_TryInvokeCodeReload_NoOverrideReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var method = typeof(TestComponent).GetMethod("UpdateValue");
|
||||
var attribute = new CodeReloadAttribute();
|
||||
CodeReloadRegistry.RegisterReloadableMethod(method, attribute);
|
||||
|
||||
// Act
|
||||
var result = CodeReloadRegistry.TryInvokeCodeReload("TestComponent.UpdateValue", testComponent);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.False, "Should return false when no code reload override is registered");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CodeReloadRegistry_GetStats_ReturnsCorrectCounts()
|
||||
{
|
||||
// Arrange - register multiple methods
|
||||
var updateMethod = typeof(TestComponent).GetMethod("UpdateValue");
|
||||
var calculateMethod = typeof(TestComponent).GetMethod("CalculateScore");
|
||||
|
||||
CodeReloadRegistry.RegisterReloadableMethod(updateMethod, new CodeReloadAttribute());
|
||||
CodeReloadRegistry.RegisterReloadableMethod(calculateMethod, new CodeReloadAttribute { Id = "CustomCalculation" });
|
||||
|
||||
// Act
|
||||
var stats = CodeReloadRegistry.GetStats();
|
||||
|
||||
// Assert
|
||||
Assert.That(stats.ReloadableMethodCount, Is.EqualTo(2));
|
||||
Assert.That(stats.ActiveOverrideCount, Is.EqualTo(0));
|
||||
Assert.That(stats.LoadedTypeCount, Is.EqualTo(0));
|
||||
Assert.That(stats.ReloadableMethodIds.Count, Is.EqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CodeReloadRegistry_ClearAllOverrides_ClearsAllState()
|
||||
{
|
||||
// Arrange - register some methods and mock some overrides
|
||||
var method = typeof(TestComponent).GetMethod("UpdateValue");
|
||||
CodeReloadRegistry.RegisterReloadableMethod(method, new CodeReloadAttribute());
|
||||
|
||||
// Act
|
||||
CodeReloadRegistry.ClearAllOverrides();
|
||||
|
||||
// Assert
|
||||
var stats = CodeReloadRegistry.GetStats();
|
||||
Assert.That(stats.ActiveOverrideCount, Is.EqualTo(0));
|
||||
Assert.That(stats.LoadedTypeCount, Is.EqualTo(0));
|
||||
|
||||
// Note: ReloadableMethodCount might not be cleared as those are the original methods, not overrides
|
||||
// This depends on implementation - currently reloadable methods are not cleared, only overrides
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator CodeReloadRegistry_MainThreadDispatch_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var method = typeof(TestComponent).GetMethod("UpdateValue");
|
||||
var attribute = new CodeReloadAttribute { RequireMainThread = true };
|
||||
CodeReloadRegistry.RegisterReloadableMethod(method, attribute);
|
||||
|
||||
// Act - test that method can be invoked (even without override, for thread safety)
|
||||
var result = CodeReloadRegistry.TryInvokeCodeReload("TestComponent.UpdateValue", testComponent);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.False); // No override registered, should return false
|
||||
|
||||
// Yield to ensure any async operations complete
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestComponent_OriginalBehavior_WorksCorrectly()
|
||||
{
|
||||
// Arrange & Act - call original methods to establish baseline
|
||||
testComponent.UpdateValue();
|
||||
var score = testComponent.CalculateScore(5);
|
||||
|
||||
// Assert
|
||||
Assert.That(testComponent.lastCalculatedValue, Is.EqualTo(10));
|
||||
Assert.That(testComponent.lastMessage, Is.EqualTo("Original UpdateValue called"));
|
||||
Assert.That(score, Is.EqualTo(10)); // 5 * 2
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 96d537e6f81b9f140a08fabafd10500e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,106 @@
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.CodeReload;
|
||||
using Unity.Pipeline.Runtime.Commands;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Integration tests for code reload CLI commands.
|
||||
/// Tests the complete workflow: CLI commands → compiler → registry → runtime behavior.
|
||||
/// </summary>
|
||||
class CodeReloadCommandTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
// Clear code reload state
|
||||
CodeReloadRegistry.ClearAllForTesting();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
CodeReloadRegistry.ClearAllForTesting();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CodeReloadStatus_ReturnsStats()
|
||||
{
|
||||
// Arrange - register a test method
|
||||
var method = typeof(SimpleTestClass).GetMethod("TestMethod");
|
||||
CodeReloadRegistry.RegisterReloadableMethod(method, new CodeReloadAttribute());
|
||||
|
||||
// Act
|
||||
var result = CodeReloadCommands.CodeReloadStatus();
|
||||
|
||||
// Assert
|
||||
Assert.That(result.Success, Is.True);
|
||||
Assert.That(result.Message, Contains.Substring("Reloadable Methods: 1"));
|
||||
Assert.That(result.Message, Contains.Substring("Active Overrides: 0"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReloadFile_WithInvalidFilename_ReturnsBadRequest()
|
||||
{
|
||||
// Act
|
||||
var result = CodeReloadCommands.ReloadFile("");
|
||||
|
||||
// Assert
|
||||
Assert.That(result.Success, Is.False);
|
||||
Assert.That(result.Error, Is.EqualTo("Bad Request"));
|
||||
Assert.That(result.ErrorDetails, Contains.Substring("cannot be empty"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReloadFile_WithNonExistentFile_ReturnsFileNotFound()
|
||||
{
|
||||
// Act
|
||||
var result = CodeReloadCommands.ReloadFile("NonExistentFile.cs");
|
||||
|
||||
// Assert
|
||||
Assert.That(result.Success, Is.False);
|
||||
Assert.That(result.Error, Is.EqualTo("File Not Found"));
|
||||
Assert.That(result.ErrorDetails, Contains.Substring("NonExistentFile.cs"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CodeReloadResponse_Success_CreatesCorrectResponse()
|
||||
{
|
||||
// Act
|
||||
var response = CodeReloadResponse.CmdSuccess("test-assembly", "Test message", new System.Collections.Generic.List<string> { "method1", "method2" }, 1500);
|
||||
|
||||
// Assert
|
||||
Assert.That(response.Success, Is.True);
|
||||
Assert.That(response.AssemblyName, Is.EqualTo("test-assembly"));
|
||||
Assert.That(response.Message, Is.EqualTo("Test message"));
|
||||
Assert.That(response.Items.Count, Is.EqualTo(2));
|
||||
Assert.That(response.ExecutionTimeMs, Is.EqualTo(1500));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CodeReloadResponse_Failure_CreatesCorrectResponse()
|
||||
{
|
||||
// Act
|
||||
var response = CodeReloadResponse.CmdFailure("Test Error", "Detailed error message", 2000);
|
||||
|
||||
// Assert
|
||||
Assert.That(response.Success, Is.False);
|
||||
Assert.That(response.Error, Is.EqualTo("Test Error"));
|
||||
Assert.That(response.ErrorDetails, Is.EqualTo("Detailed error message"));
|
||||
Assert.That(response.ExecutionTimeMs, Is.EqualTo(2000));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple test class for code reload testing.
|
||||
/// </summary>
|
||||
public class SimpleTestClass
|
||||
{
|
||||
public int value;
|
||||
|
||||
public void TestMethod()
|
||||
{
|
||||
value = 42;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3f81555b71859b94c8349361be0d1a9b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Compilation;
|
||||
using Unity.Pipeline.CodeReload;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for CodeReloadCompiler's cleanup of versioned code reload assemblies.
|
||||
/// </summary>
|
||||
class CodeReloadCompilerTests
|
||||
{
|
||||
private string testAssemblyDir;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
// Keep test files OUTSIDE Assets/ (project-root Temp/, which Unity ignores) so the
|
||||
// editor never imports them — an import triggers a domain reload that tears down the
|
||||
// live pipeline server.
|
||||
testAssemblyDir = Path.GetFullPath(Path.Combine("Temp", "PipelineCodeReloadTests", "CompilerTests"));
|
||||
Directory.CreateDirectory(testAssemblyDir);
|
||||
|
||||
// Clear code reload state
|
||||
CodeReloadRegistry.ClearAllForTesting();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
// Clean up test files
|
||||
if (Directory.Exists(testAssemblyDir))
|
||||
{
|
||||
Directory.Delete(testAssemblyDir, true);
|
||||
}
|
||||
|
||||
// Clean up code reload state and temp files
|
||||
CodeReloadRegistry.ClearAllForTesting();
|
||||
CodeReloadCompiler.CleanupCodeReloadDlls();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CleanupCodeReloadDlls_WithNoFiles_SucceedsGracefully()
|
||||
{
|
||||
// Act
|
||||
var result = CodeReloadCompiler.CleanupCodeReloadDlls();
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(result.Success, "Cleanup should succeed even when no files exist");
|
||||
Assert.AreEqual(0, result.DeletedFiles.Count, "Should not delete any files");
|
||||
Assert.That(result.Message, Does.Contain("No code reload directory").Or.Contain("0 old DLL versions"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CleanupCodeReloadDlls_WithVersionedDlls_KeepsOnlyLatestVersion()
|
||||
{
|
||||
// Arrange - three versions of one assembly, plus an unversioned DLL cleanup must leave alone
|
||||
var v1 = Path.Combine(testAssemblyDir, "Sample_001.dll");
|
||||
var v2 = Path.Combine(testAssemblyDir, "Sample_002.dll");
|
||||
var v3 = Path.Combine(testAssemblyDir, "Sample_003.dll");
|
||||
var unversioned = Path.Combine(testAssemblyDir, "Plain.dll");
|
||||
File.WriteAllText(v1, "fake dll v1");
|
||||
File.WriteAllText(v2, "fake dll v2");
|
||||
File.WriteAllText(v3, "fake dll v3");
|
||||
File.WriteAllText(unversioned, "fake dll");
|
||||
|
||||
// Act
|
||||
var result = CodeReloadCompiler.CleanupCodeReloadDlls(testAssemblyDir);
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(result.Success, $"Cleanup should succeed. Message: {result.Message}");
|
||||
Assert.GreaterOrEqual(result.ExecutionTimeMs, 0, "Should record execution time (0ms is acceptable for fast cleanup)");
|
||||
Assert.IsFalse(File.Exists(v1), "Version 001 should be deleted");
|
||||
Assert.IsFalse(File.Exists(v2), "Version 002 should be deleted");
|
||||
Assert.IsTrue(File.Exists(v3), "Latest version should be kept");
|
||||
Assert.IsTrue(File.Exists(unversioned), "Unversioned DLLs should be left alone");
|
||||
CollectionAssert.AreEquivalent(new[] { "Sample_001.dll", "Sample_002.dll" }, result.DeletedFiles);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CleanupCodeReloadDlls_WithActiveOverrides_ClearsRegistry()
|
||||
{
|
||||
// Arrange - register a reloadable target and an override for it
|
||||
var target = typeof(CleanupTarget).GetMethod(nameof(CleanupTarget.Tick));
|
||||
CodeReloadRegistry.RegisterReloadableMethod(target, new CodeReloadAttribute { Id = "CleanupTarget.Tick" });
|
||||
|
||||
var overrideMethod = typeof(CleanupOverrides).GetMethod(nameof(CleanupOverrides.Tick));
|
||||
var overrideAttribute = overrideMethod.GetCustomAttribute<CodeReloadOverrideMethodAttribute>();
|
||||
Assert.IsTrue(CodeReloadRegistry.RegisterMethodOverride(overrideMethod, overrideAttribute, typeof(CleanupOverrides)),
|
||||
"Override should register against the reloadable target");
|
||||
Assert.Greater(CodeReloadRegistry.GetStats().ActiveOverrideCount, 0, "Precondition: an override is active");
|
||||
|
||||
// Act
|
||||
var result = CodeReloadCompiler.CleanupCodeReloadDlls(testAssemblyDir);
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(result.Success, $"Cleanup should succeed. Message: {result.Message}");
|
||||
Assert.AreEqual(0, CodeReloadRegistry.GetStats().ActiveOverrideCount, "Registry should be cleared after cleanup");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Target whose method the cleanup test registers as reloadable.
|
||||
/// </summary>
|
||||
public class CleanupTarget
|
||||
{
|
||||
public int value;
|
||||
|
||||
public void Tick()
|
||||
{
|
||||
value = 42;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override for <see cref="CleanupTarget.Tick"/>, in the shape the reload pipeline generates.
|
||||
/// </summary>
|
||||
public static class CleanupOverrides
|
||||
{
|
||||
[CodeReloadOverrideMethod("CleanupTarget.Tick")]
|
||||
public static void Tick(CleanupTarget instance)
|
||||
{
|
||||
instance.value = 99;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 22dc4e614651b5b4e8d344bd9857fcf6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,55 @@
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.CodeReload;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Round-trip tests for the editor→player code-reload push wire format
|
||||
/// ({typeName, methodNames, il}).
|
||||
/// </summary>
|
||||
class CodeReloadConnectWireTests
|
||||
{
|
||||
static readonly byte[] k_Il = { 1, 2, 3, 4, 5 };
|
||||
|
||||
[Test]
|
||||
public void Encode_RoundTrips()
|
||||
{
|
||||
var payload = PipelineCodeReloadConnect.Encode("MyType", new[] { "A", "B" }, k_Il);
|
||||
|
||||
Assert.IsTrue(PipelineCodeReloadConnect.TryDecode(
|
||||
payload, out var typeName, out var methods, out var il));
|
||||
Assert.AreEqual("MyType", typeName);
|
||||
CollectionAssert.AreEqual(new[] { "A", "B" }, methods);
|
||||
CollectionAssert.AreEqual(k_Il, il);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Encode_EmptyIl_RoundTrips()
|
||||
{
|
||||
var payload = PipelineCodeReloadConnect.Encode("T", new[] { "A" }, null);
|
||||
|
||||
Assert.IsTrue(PipelineCodeReloadConnect.TryDecode(
|
||||
payload, out var typeName, out var methods, out var il));
|
||||
Assert.AreEqual("T", typeName);
|
||||
CollectionAssert.AreEqual(new[] { "A" }, methods);
|
||||
Assert.AreEqual(0, il.Length);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TruncatedPayload_FailsCleanly()
|
||||
{
|
||||
var payload = PipelineCodeReloadConnect.Encode("MyType", new[] { "A" }, k_Il);
|
||||
|
||||
for (int cut = 1; cut < payload.Length; cut++)
|
||||
{
|
||||
var truncated = new byte[payload.Length - cut];
|
||||
System.Array.Copy(payload, truncated, truncated.Length);
|
||||
// Some truncations still parse as a shorter-but-valid IL section; the invariant
|
||||
// that matters is no exception and no torn decode reporting success with the
|
||||
// full IL length.
|
||||
if (PipelineCodeReloadConnect.TryDecode(truncated, out _, out _, out var il))
|
||||
Assert.Less(il.Length, k_Il.Length);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ac3dc81c245284dfc8e25b7be9dadf40
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,328 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.CodeReload;
|
||||
using Unity.Pipeline.Runtime.Commands;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Probe with a method-level [CodeReload] coroutine. The IL weaver routes IEnumerator
|
||||
/// returns through TryInvokeCodeReloadResult (like any value-returning method), so a
|
||||
/// registered override supplies the iterator that the caller — in Unity, StartCoroutine —
|
||||
/// actually drives.
|
||||
/// </summary>
|
||||
public class CoroutineReloadProbe
|
||||
{
|
||||
public int originalRuns;
|
||||
public int hotRuns;
|
||||
public int touched;
|
||||
|
||||
[CodeReload]
|
||||
public IEnumerator Run()
|
||||
{
|
||||
originalRuns++; // original body
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[CodeReload]
|
||||
public void Touch()
|
||||
{
|
||||
touched++; // original body
|
||||
}
|
||||
}
|
||||
|
||||
static class CoroutineOverrides
|
||||
{
|
||||
[CodeReloadOverrideMethod("CoroutineReloadProbe.Run")]
|
||||
public static IEnumerator Run(CoroutineReloadProbe instance)
|
||||
{
|
||||
instance.hotRuns++;
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Probe for the INTERPRETER backend: the reloaded coroutine body runs as a IlInterpreter
|
||||
/// state machine behind the ScriptEnumerator bridge (what an IL2CPP player does).
|
||||
/// </summary>
|
||||
public class IlInterpreterCoroutineProbe
|
||||
{
|
||||
public int originalSteps;
|
||||
public int hotSteps;
|
||||
|
||||
[CodeReload]
|
||||
public IEnumerator Run()
|
||||
{
|
||||
originalSteps++; // original body
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coroutine code reload: `[CodeReload] IEnumerator` methods weave a result dispatch at
|
||||
/// iterator CREATION, so each new StartCoroutine-style call picks up the active override
|
||||
/// while in-flight iterators keep running the code they were created from.
|
||||
/// </summary>
|
||||
class CodeReloadCoroutineTests
|
||||
{
|
||||
[SetUp]
|
||||
public void Setup() => CodeReloadRegistry.ClearAllForTesting();
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => CodeReloadRegistry.ClearAllForTesting();
|
||||
|
||||
[Test]
|
||||
public void Transform_CoroutineBody_EmitsIteratorOverride_KeepingYields()
|
||||
{
|
||||
const string coroutineSource = @"
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using Unity.Pipeline.CodeReload;
|
||||
|
||||
public class CoDemo : MonoBehaviour
|
||||
{
|
||||
public int step;
|
||||
|
||||
[CodeReload]
|
||||
IEnumerator Run()
|
||||
{
|
||||
step = 1;
|
||||
yield return null;
|
||||
step = 2;
|
||||
}
|
||||
}";
|
||||
// The transform lifts the body into a static method with the same IEnumerator
|
||||
// return type; the yields survive, so the override compiles as a normal C# iterator.
|
||||
var bodies = new Dictionary<string, string> { ["Run"] = "" };
|
||||
var output = SourceCodeTransformer.TransformMethodBodies(
|
||||
bodies, "CoDemo", new Dictionary<string, MethodSignatureInfo>(), coroutineSource);
|
||||
|
||||
StringAssert.Contains("[CodeReloadOverrideMethod(\"CoDemo.Run\")]", output);
|
||||
StringAssert.Contains("public static IEnumerator Run(CoDemo instance", output);
|
||||
StringAssert.Contains("yield return null", output);
|
||||
StringAssert.Contains("instance.step", output);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WovenDispatch_RoutesNewIteratorsToOverride()
|
||||
{
|
||||
CodeReloadRegistry.RegisterReloadableMethod(
|
||||
typeof(CoroutineReloadProbe).GetMethod(nameof(CoroutineReloadProbe.Run)),
|
||||
new CodeReloadAttribute());
|
||||
|
||||
var registered = CodeReloadRegistry.RegisterMethodOverride(
|
||||
typeof(CoroutineOverrides).GetMethod(nameof(CoroutineOverrides.Run)),
|
||||
new CodeReloadOverrideMethodAttribute("CoroutineReloadProbe.Run"),
|
||||
typeof(CoroutineOverrides),
|
||||
out var reason);
|
||||
Assert.IsTrue(registered, reason);
|
||||
|
||||
// Run() was woven (IEnumerator returns go through TryInvokeCodeReloadResult), so the
|
||||
// returned iterator IS the override's state machine.
|
||||
var probe = new CoroutineReloadProbe();
|
||||
var routine = probe.Run();
|
||||
Assert.IsTrue(routine.MoveNext()); // iterators are lazy — body runs on first MoveNext
|
||||
|
||||
Assert.AreEqual(0, probe.originalRuns, "Original body must not run while an override is active");
|
||||
Assert.AreEqual(1, probe.hotRuns, "Override iterator should have run");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WovenDispatch_InFlightIteratorKeepsOldBody_NextCallGetsNew()
|
||||
{
|
||||
CodeReloadRegistry.RegisterReloadableMethod(
|
||||
typeof(CoroutineReloadProbe).GetMethod(nameof(CoroutineReloadProbe.Run)),
|
||||
new CodeReloadAttribute());
|
||||
|
||||
var probe = new CoroutineReloadProbe();
|
||||
var inFlight = probe.Run(); // created BEFORE the override lands
|
||||
|
||||
CodeReloadRegistry.RegisterMethodOverride(
|
||||
typeof(CoroutineOverrides).GetMethod(nameof(CoroutineOverrides.Run)),
|
||||
new CodeReloadOverrideMethodAttribute("CoroutineReloadProbe.Run"),
|
||||
typeof(CoroutineOverrides),
|
||||
out _);
|
||||
|
||||
inFlight.MoveNext();
|
||||
Assert.AreEqual(1, probe.originalRuns, "In-flight iterator keeps the body it was created from");
|
||||
Assert.AreEqual(0, probe.hotRuns);
|
||||
|
||||
var next = probe.Run(); // created AFTER — dispatches to the override
|
||||
next.MoveNext();
|
||||
Assert.AreEqual(1, probe.originalRuns);
|
||||
Assert.AreEqual(1, probe.hotRuns, "Next iterator creation should route to the override");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReloadFileInPlace_CoroutineOnlyFile_Succeeds()
|
||||
{
|
||||
CodeReloadRegistry.RegisterReloadableMethod(
|
||||
typeof(CoroutineReloadProbe).GetMethod(nameof(CoroutineReloadProbe.Run)),
|
||||
new CodeReloadAttribute());
|
||||
|
||||
var editedSource = @"
|
||||
using System.Collections;
|
||||
using Unity.Pipeline.CodeReload;
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
public class CoroutineReloadProbe
|
||||
{
|
||||
public int originalRuns;
|
||||
public int hotRuns;
|
||||
|
||||
[CodeReload]
|
||||
public IEnumerator Run()
|
||||
{
|
||||
hotRuns = 99;
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
}";
|
||||
var path = Path.Combine(Application.temporaryCachePath, "CoroutineReloadProbe_edit.cs");
|
||||
File.WriteAllText(path, editedSource);
|
||||
|
||||
try
|
||||
{
|
||||
var response = CodeReloadCommands.ReloadFile(path);
|
||||
Assert.IsTrue(response.Success,
|
||||
$"reload_file should succeed for a coroutine-only file. Error: {response.ErrorDetails}");
|
||||
|
||||
var probe = new CoroutineReloadProbe();
|
||||
var routine = probe.Run();
|
||||
routine.MoveNext(); // iterators are lazy — body only runs on first MoveNext
|
||||
Assert.AreEqual(0, probe.originalRuns, "Original body must not run after reload");
|
||||
Assert.AreEqual(99, probe.hotRuns, "Edited coroutine body should run via the woven dispatch");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReloadFileInPlace_CoroutineNextToVoidMethod_ReloadsBoth()
|
||||
{
|
||||
CodeReloadRegistry.RegisterReloadableMethod(
|
||||
typeof(CoroutineReloadProbe).GetMethod(nameof(CoroutineReloadProbe.Run)),
|
||||
new CodeReloadAttribute());
|
||||
CodeReloadRegistry.RegisterReloadableMethod(
|
||||
typeof(CoroutineReloadProbe).GetMethod(nameof(CoroutineReloadProbe.Touch)),
|
||||
new CodeReloadAttribute());
|
||||
|
||||
var editedSource = @"
|
||||
using System.Collections;
|
||||
using Unity.Pipeline.CodeReload;
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
public class CoroutineReloadProbe
|
||||
{
|
||||
public int originalRuns;
|
||||
public int hotRuns;
|
||||
public int touched;
|
||||
|
||||
[CodeReload]
|
||||
public IEnumerator Run()
|
||||
{
|
||||
hotRuns = 99;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[CodeReload]
|
||||
public void Touch()
|
||||
{
|
||||
touched = 42;
|
||||
}
|
||||
}
|
||||
}";
|
||||
var path = Path.Combine(Application.temporaryCachePath, "CoroutineReloadProbe_mixed_edit.cs");
|
||||
File.WriteAllText(path, editedSource);
|
||||
|
||||
try
|
||||
{
|
||||
var result = InPlaceReloadProcessor.ProcessSourceFileOnMainThread(path);
|
||||
|
||||
Assert.IsTrue(result.Success, $"Mixed file should reload. Error: {result.ErrorMessage}");
|
||||
CollectionAssert.Contains(result.ExtractedMethods, "Touch");
|
||||
CollectionAssert.Contains(result.ExtractedMethods, "Run");
|
||||
|
||||
var probe = new CoroutineReloadProbe();
|
||||
probe.Touch();
|
||||
Assert.AreEqual(42, probe.touched, "Edited void body should run via the woven dispatch");
|
||||
|
||||
var routine = probe.Run();
|
||||
routine.MoveNext();
|
||||
Assert.AreEqual(0, probe.originalRuns);
|
||||
Assert.AreEqual(99, probe.hotRuns, "Edited coroutine body should run via the woven dispatch");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReloadFileInterpreter_Coroutine_RunsAsInterpretedStateMachine()
|
||||
{
|
||||
// The IL2CPP-shaped path: the edited coroutine compiles to a state machine the
|
||||
// IlInterpreter VM interprets; the woven dispatch returns a ScriptEnumerator bridge
|
||||
// that the caller (in Unity, StartCoroutine) drives like any IEnumerator.
|
||||
CodeReloadRegistry.RegisterReloadableMethod(
|
||||
typeof(IlInterpreterCoroutineProbe).GetMethod(nameof(IlInterpreterCoroutineProbe.Run)),
|
||||
new CodeReloadAttribute());
|
||||
|
||||
var editedSource = @"
|
||||
using System.Collections;
|
||||
using Unity.Pipeline.CodeReload;
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
public class IlInterpreterCoroutineProbe
|
||||
{
|
||||
public int originalSteps;
|
||||
public int hotSteps;
|
||||
|
||||
[CodeReload]
|
||||
public IEnumerator Run()
|
||||
{
|
||||
hotSteps = 1;
|
||||
yield return null;
|
||||
hotSteps = 2;
|
||||
yield return new UnityEngine.WaitForSeconds(0.25f);
|
||||
hotSteps = 3;
|
||||
}
|
||||
}
|
||||
}";
|
||||
var path = Path.Combine(Application.temporaryCachePath, "IlInterpreterCoroutineProbe_edit.cs");
|
||||
File.WriteAllText(path, editedSource);
|
||||
|
||||
try
|
||||
{
|
||||
var response = CodeReloadCommands.ReloadFileEditorInterpreter(path);
|
||||
Assert.IsTrue(response.Success,
|
||||
$"reload_file_editor_interpreter should succeed for a coroutine. Error: {response.ErrorDetails}");
|
||||
|
||||
var probe = new IlInterpreterCoroutineProbe();
|
||||
var routine = probe.Run(); // woven dispatch → interpreter → bridge
|
||||
|
||||
Assert.IsTrue(routine.MoveNext());
|
||||
Assert.IsNull(routine.Current, "yield return null must surface as a null step");
|
||||
Assert.AreEqual(1, probe.hotSteps);
|
||||
|
||||
Assert.IsTrue(routine.MoveNext());
|
||||
Assert.AreEqual(2, probe.hotSteps);
|
||||
Assert.IsInstanceOf<WaitForSeconds>(routine.Current,
|
||||
"a host object yielded from the interpreted body must cross the bridge intact");
|
||||
|
||||
Assert.IsFalse(routine.MoveNext());
|
||||
Assert.AreEqual(3, probe.hotSteps);
|
||||
Assert.AreEqual(0, probe.originalSteps, "original compiled body must not have run");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 081d75116f3990a4398c6f8c474fa2f9
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e5830856fb74475c9b4284d67bb9c991
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
using System.Xml;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Editor.BuildProcessors;
|
||||
using Unity.Pipeline.CodeReload;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the generated link.xml that preserves the interpreter's host-binding surface under
|
||||
/// IL2CPP managed stripping. The build-time contribution runs only inside a real player build, but
|
||||
/// the XML generator is pure and editor-callable, so its output is verified directly here.
|
||||
/// </summary>
|
||||
class CodeReloadLinkXmlGeneratorTests
|
||||
{
|
||||
[Test]
|
||||
public void GenerateLinkXml_IsWellFormedLinkerDocument()
|
||||
{
|
||||
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml();
|
||||
|
||||
var doc = new XmlDocument();
|
||||
Assert.DoesNotThrow(() => doc.LoadXml(xml), "Generated link.xml must be well-formed XML.");
|
||||
Assert.AreEqual("linker", doc.DocumentElement.Name, "Root element must be <linker>.");
|
||||
Assert.IsNotEmpty(doc.SelectNodes("/linker/assembly"), "Expected at least one <assembly> group.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GenerateLinkXml_PreservesUnityEngineColor_TheGreenYellowCase()
|
||||
{
|
||||
// Color.greenYellow exists in the Unity API but is stripped from an IL2CPP player when the
|
||||
// built game never references it. AllowType<Color>() registers the whole type, so preserving
|
||||
// Color with preserve="all" is exactly what keeps greenYellow (and every other member)
|
||||
// callable from reloaded code.
|
||||
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml();
|
||||
|
||||
var doc = new XmlDocument();
|
||||
doc.LoadXml(xml);
|
||||
|
||||
var colorNode = doc.SelectSingleNode(
|
||||
"/linker/assembly/type[@fullname='UnityEngine.Color']");
|
||||
Assert.IsNotNull(colorNode, "UnityEngine.Color must be preserved so its members survive stripping.");
|
||||
Assert.AreEqual("all", colorNode.Attributes["preserve"]?.Value,
|
||||
"Color must be preserved with preserve=\"all\" to keep members like greenYellow.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GenerateLinkXml_GroupsColorUnderCoreModule()
|
||||
{
|
||||
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml();
|
||||
|
||||
var doc = new XmlDocument();
|
||||
doc.LoadXml(xml);
|
||||
|
||||
var node = doc.SelectSingleNode(
|
||||
"/linker/assembly[@fullname='UnityEngine.CoreModule']/type[@fullname='UnityEngine.Color']");
|
||||
Assert.IsNotNull(node, "Color should be grouped under its owning assembly, UnityEngine.CoreModule.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GenerateLinkXml_WithPlayerAssemblies_PreservesUserAndEngineModulesWholesale()
|
||||
{
|
||||
// Demand-time auto-bind lets reloaded code call into any user or UnityEngine type, so the
|
||||
// dev build must preserve those assemblies wholesale; BCL and Unity.* package assemblies
|
||||
// stay per-type (the auto-bind policy declines the BCL, and Unity.* packages are AOT-heavy).
|
||||
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml(
|
||||
new[] { "Assembly-CSharp", "MyGame.Gameplay", "UnityEngine.CoreModule", "UnityEngine.AIModule",
|
||||
"System.Core", "mscorlib", "Unity.Burst", "Unity.Pipeline" },
|
||||
out _);
|
||||
|
||||
var doc = new XmlDocument();
|
||||
doc.LoadXml(xml);
|
||||
|
||||
foreach (var expected in new[]
|
||||
{ "Assembly-CSharp", "MyGame.Gameplay", "UnityEngine.CoreModule", "UnityEngine.AIModule" })
|
||||
{
|
||||
var node = doc.SelectSingleNode($"/linker/assembly[@fullname='{expected}']");
|
||||
Assert.IsNotNull(node, $"{expected} must be preserved wholesale.");
|
||||
Assert.AreEqual("all", node.Attributes["preserve"]?.Value,
|
||||
$"{expected} must carry assembly-level preserve=\"all\".");
|
||||
Assert.AreEqual(0, node.ChildNodes.Count,
|
||||
$"{expected} is wholesale — per-type entries are subsumed.");
|
||||
}
|
||||
|
||||
foreach (var excluded in new[] { "System.Core", "mscorlib", "Unity.Burst", "Unity.Pipeline" })
|
||||
{
|
||||
var node = doc.SelectSingleNode($"/linker/assembly[@fullname='{excluded}']");
|
||||
Assert.IsTrue(node == null || node.Attributes["preserve"] == null,
|
||||
$"{excluded} must not be preserved wholesale.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GenerateLinkXml_WithoutPlayerAssemblies_OnlyInterpreterEngineIsWholesale()
|
||||
{
|
||||
// Null player-assembly set (unreadable linker input, or the parameterless test entry
|
||||
// point) degrades to per-type host-surface preservation plus the always-on interpreter
|
||||
// engine assemblies — nothing else may be preserved wholesale.
|
||||
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml();
|
||||
|
||||
var doc = new XmlDocument();
|
||||
doc.LoadXml(xml);
|
||||
|
||||
foreach (XmlNode asm in doc.SelectNodes("/linker/assembly"))
|
||||
{
|
||||
var name = asm.Attributes["fullname"]?.Value;
|
||||
if (name == "Unity.Pipeline.IlInterpreter" ||
|
||||
name == "UnityPipeline.System.Reflection.Metadata" ||
|
||||
name == "UnityPipeline.System.Collections.Immutable")
|
||||
continue;
|
||||
Assert.IsNull(asm.Attributes["preserve"],
|
||||
$"without a player assembly set '{name}' may not be preserved wholesale");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GenerateLinkXml_AlwaysPreservesInterpreterEngineAssembliesWholesale()
|
||||
{
|
||||
// The interpreter parses pushed override IL via System.Reflection.Metadata and resolves
|
||||
// host members by name; under IL2CPP stripping those paths look unused. This preservation
|
||||
// used to live in a static Runtime/link.xml, but that file applied to release builds too
|
||||
// (real size cost for machinery that is compiled out of release players). The generator
|
||||
// only contributes to development builds, so it now owns this set — with or without a
|
||||
// player-assembly list.
|
||||
foreach (var xml in new[]
|
||||
{
|
||||
CodeReloadLinkXmlGenerator.GenerateLinkXml(),
|
||||
CodeReloadLinkXmlGenerator.GenerateLinkXml(new[] { "Assembly-CSharp" }, out _),
|
||||
})
|
||||
{
|
||||
var doc = new XmlDocument();
|
||||
doc.LoadXml(xml);
|
||||
|
||||
foreach (var expected in new[]
|
||||
{ "Unity.Pipeline.IlInterpreter", "UnityPipeline.System.Reflection.Metadata", "UnityPipeline.System.Collections.Immutable" })
|
||||
{
|
||||
var node = doc.SelectSingleNode($"/linker/assembly[@fullname='{expected}']");
|
||||
Assert.IsNotNull(node, $"{expected} must be preserved so the interpreter survives stripping.");
|
||||
Assert.AreEqual("all", node.Attributes["preserve"]?.Value,
|
||||
$"{expected} must carry assembly-level preserve=\"all\".");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GenerateLinkXml_UnityPrefixedGameAssemblies_AreTreatedAsUserCode()
|
||||
{
|
||||
// A bare "Unity"/"System" prefix would misclassify game assemblies that merely start
|
||||
// with the word — "UnityChanDemo", "SystemsRuntime" — as engine/BCL code, silently
|
||||
// dropping them from wholesale preservation and from the [CodeReload] target scan
|
||||
// (both share IsNonUserAssembly). Only real engine/BCL names are non-user.
|
||||
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml(
|
||||
new[] { "UnityChanDemo", "SystemsRuntime", "Unity", "Unity.Burst", "System.Core" },
|
||||
out _);
|
||||
|
||||
var doc = new XmlDocument();
|
||||
doc.LoadXml(xml);
|
||||
|
||||
foreach (var user in new[] { "UnityChanDemo", "SystemsRuntime" })
|
||||
{
|
||||
var node = doc.SelectSingleNode($"/linker/assembly[@fullname='{user}']");
|
||||
Assert.IsNotNull(node, $"{user} is user code and must be preserved wholesale.");
|
||||
Assert.AreEqual("all", node.Attributes["preserve"]?.Value,
|
||||
$"{user} must carry assembly-level preserve=\"all\".");
|
||||
}
|
||||
|
||||
foreach (var engine in new[] { "Unity", "Unity.Burst", "System.Core" })
|
||||
{
|
||||
var node = doc.SelectSingleNode($"/linker/assembly[@fullname='{engine}']");
|
||||
Assert.IsTrue(node == null || node.Attributes["preserve"] == null,
|
||||
$"{engine} is engine/BCL code and must not be preserved wholesale.");
|
||||
}
|
||||
}
|
||||
|
||||
// Rescue probe: this test assembly is Unity.Pipeline.Tests.Editor — it matches the
|
||||
// engine-assembly name pattern, yet carries this [CodeReload] member, standing in for a
|
||||
// game assembly named e.g. "Unity.MyGame".
|
||||
class EngineNamedAssemblyProbe { [CodeReload] void Tick() { } }
|
||||
|
||||
[Test]
|
||||
public void GenerateLinkXml_FindsCodeReloadTargets_InEngineNamedAssemblies()
|
||||
{
|
||||
// The [CodeReload] attribute must be authoritative for the target scan; the engine-name
|
||||
// check is only a fast path. [CodeReload] lives in Unity.Pipeline, so an engine-named
|
||||
// assembly that references Unity.Pipeline can carry targets and must still be scanned —
|
||||
// otherwise its private state is stripped and reload fails on device only.
|
||||
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml();
|
||||
|
||||
var expected = typeof(EngineNamedAssemblyProbe).FullName.Replace('+', '/');
|
||||
StringAssert.Contains($"<type fullname=\"{expected}\" preserve=\"all\"/>", xml,
|
||||
"[CodeReload] targets in an engine-named assembly must still be preserved.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GenerateLinkXml_WithPlayerAssemblies_DropsReloadTargetsNotStagedForTheBuild()
|
||||
{
|
||||
// The AppDomain scan sees editor-only and test assemblies that are not part of the
|
||||
// player build; their [CodeReload] entries are noise in a game build's link.xml. When
|
||||
// the staged set is known, only reload targets actually in the build are emitted —
|
||||
// which also covers user test asmdefs. A test-player build stages its test
|
||||
// assemblies, so fixtures keep their entries exactly where they matter.
|
||||
var probe = typeof(EngineNamedAssemblyProbe).FullName.Replace('+', '/');
|
||||
|
||||
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml(new[] { "Assembly-CSharp" }, out _);
|
||||
StringAssert.DoesNotContain(probe, xml,
|
||||
"an unstaged assembly must not contribute reload-target entries");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GenerateLinkXml_PromotesStagedEngineNamedAssembliesCarryingCodeReloadTargets()
|
||||
{
|
||||
// A staged, engine-named assembly carrying [CodeReload] members has identified itself
|
||||
// as user code participating in code reload — a package-like name (e.g. "Unity.MyGame")
|
||||
// must not demote it. Preserving only its [CodeReload] types would leave the rest of the
|
||||
// assembly strippable, so demand-time auto-bind could hit stripped members on device
|
||||
// only; instead it is preserved wholesale like any other user assembly. This test
|
||||
// assembly (Unity.Pipeline.Tests.Editor, carrying the probe above) plays that role.
|
||||
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml(
|
||||
new[] { "Assembly-CSharp", "Unity.Pipeline.Tests.Editor" }, out _);
|
||||
|
||||
var doc = new XmlDocument();
|
||||
doc.LoadXml(xml);
|
||||
|
||||
var node = doc.SelectSingleNode("/linker/assembly[@fullname='Unity.Pipeline.Tests.Editor']");
|
||||
Assert.IsNotNull(node, "the promoted assembly must appear in the link.xml");
|
||||
Assert.AreEqual("all", node.Attributes["preserve"]?.Value,
|
||||
"the promoted assembly must carry assembly-level preserve=\"all\"");
|
||||
Assert.AreEqual(0, node.ChildNodes.Count,
|
||||
"wholesale preservation subsumes the per-type [CodeReload] entries");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CollectPlayerAssemblyNames_WithEmptyInputDir_RecoversUnityEngineModulesFromTheEditor()
|
||||
{
|
||||
// The linker input directory is empty at IUnityLinkerProcessor time on some build backends
|
||||
// (the symptom that stripped UITK ctors on device: a live-added `new Slider()` couldn't
|
||||
// resolve because UnityEngine.UIElementsModule wasn't preserved wholesale). The collector
|
||||
// must fall back to the editor's own assembly view so wholesale preservation still fires.
|
||||
var names = CodeReloadLinkXmlGenerator.CollectPlayerAssemblyNames("/no/such/linker/input/dir");
|
||||
|
||||
Assert.IsNotNull(names, "an empty input dir must still yield the editor's player-assembly view");
|
||||
CollectionAssert.Contains(names, "UnityEngine.UIElementsModule",
|
||||
"the UnityEngine module carrying Slider must be recovered so its ctors survive stripping");
|
||||
CollectionAssert.Contains(names, "UnityEngine.CoreModule",
|
||||
"core UnityEngine modules must be recovered from the loaded editor assemblies");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CollectPlayerAssemblyNames_FeedsWholesalePreservationOfUIElements()
|
||||
{
|
||||
// End-to-end for the Slider fix: the recovered assembly set, fed through the generator,
|
||||
// must preserve UnityEngine.UIElementsModule wholesale (assembly-level preserve="all"),
|
||||
// which is what keeps Slider's constructors callable from a reloaded body on device.
|
||||
var names = CodeReloadLinkXmlGenerator.CollectPlayerAssemblyNames(null);
|
||||
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml(names, out _);
|
||||
|
||||
var doc = new XmlDocument();
|
||||
doc.LoadXml(xml);
|
||||
|
||||
var node = doc.SelectSingleNode("/linker/assembly[@fullname='UnityEngine.UIElementsModule']");
|
||||
Assert.IsNotNull(node, "UnityEngine.UIElementsModule must be preserved so Slider's ctors survive.");
|
||||
Assert.AreEqual("all", node.Attributes["preserve"]?.Value,
|
||||
"UnityEngine.UIElementsModule must carry assembly-level preserve=\"all\".");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GenerateLinkXml_EveryTypeIsPreservedAll()
|
||||
{
|
||||
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml();
|
||||
|
||||
var doc = new XmlDocument();
|
||||
doc.LoadXml(xml);
|
||||
|
||||
foreach (XmlNode type in doc.SelectNodes("/linker/assembly/type"))
|
||||
{
|
||||
Assert.AreEqual("all", type.Attributes["preserve"]?.Value,
|
||||
$"Type {type.Attributes["fullname"]?.Value} must use preserve=\"all\".");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7819dc6a8789ae64f8dbe542ffae0c38
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,170 @@
|
||||
using System.Reflection;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.CodeReload;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for CodeReloadRegistry to verify method registration and statistics.
|
||||
/// Helps diagnose issues with code reload method registration.
|
||||
/// </summary>
|
||||
class CodeReloadRegistryTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
CodeReloadRegistry.ClearAllForTesting();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
CodeReloadRegistry.ClearAllForTesting();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RegisterMethodOverride_DirectCall_UpdatesStats()
|
||||
{
|
||||
// Test direct registration to verify registry behavior
|
||||
|
||||
var method = typeof(TestRegistryClass).GetMethod("TestMethod");
|
||||
var attribute = method.GetCustomAttribute<CodeReloadOverrideMethodAttribute>();
|
||||
var type = typeof(TestRegistryClass);
|
||||
|
||||
Assert.IsNotNull(method, "Test method should exist");
|
||||
Assert.IsNotNull(attribute, "Test attribute should exist");
|
||||
|
||||
// Get initial stats
|
||||
var initialStats = CodeReloadRegistry.GetStats();
|
||||
|
||||
// CRITICAL: First register the target method as reloadable (this was missing!)
|
||||
// Create a mock target method that matches the attribute's TargetMethodId
|
||||
var targetMethod = typeof(MockTargetClass1).GetMethod("TargetMethod1");
|
||||
var reloadableAttr = new CodeReloadAttribute { Id = "TargetClass1.TargetMethod1" };
|
||||
CodeReloadRegistry.RegisterReloadableMethod(targetMethod, reloadableAttr);
|
||||
|
||||
// Register the type and method override
|
||||
CodeReloadRegistry.RegisterCodeReloadType(type, "TestAssembly");
|
||||
CodeReloadRegistry.RegisterMethodOverride(method, attribute, type);
|
||||
|
||||
// Get final stats
|
||||
var finalStats = CodeReloadRegistry.GetStats();
|
||||
|
||||
// Verify stats changed
|
||||
Assert.Greater(finalStats.ActiveOverrideCount, initialStats.ActiveOverrideCount, "Active override count should increase");
|
||||
Assert.Greater(finalStats.LoadedTypeCount, initialStats.LoadedTypeCount, "Loaded type count should increase");
|
||||
Assert.Greater(finalStats.ReloadableMethodCount, initialStats.ReloadableMethodCount, "Reloadable method count should increase");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetStats_AfterRegistration_ReturnsCorrectCounts()
|
||||
{
|
||||
// Test that GetStats returns accurate counts
|
||||
|
||||
var stats1 = CodeReloadRegistry.GetStats();
|
||||
Assert.AreEqual(0, stats1.ActiveOverrideCount, "Should start with 0 active overrides");
|
||||
|
||||
// Register target methods as reloadable first (this was missing!)
|
||||
var targetMethod1 = typeof(MockTargetClass1).GetMethod("TargetMethod1");
|
||||
var reloadableAttr1 = new CodeReloadAttribute { Id = "TargetClass1.TargetMethod1" };
|
||||
CodeReloadRegistry.RegisterReloadableMethod(targetMethod1, reloadableAttr1);
|
||||
|
||||
var targetMethod2 = typeof(MockTargetClass2).GetMethod("TargetMethod2");
|
||||
var reloadableAttr2 = new CodeReloadAttribute { Id = "TargetClass2.TargetMethod2" };
|
||||
CodeReloadRegistry.RegisterReloadableMethod(targetMethod2, reloadableAttr2);
|
||||
|
||||
// Register multiple methods
|
||||
var method1 = typeof(TestRegistryClass).GetMethod("TestMethod");
|
||||
var attribute1 = method1.GetCustomAttribute<CodeReloadOverrideMethodAttribute>();
|
||||
CodeReloadRegistry.RegisterCodeReloadType(typeof(TestRegistryClass), "TestAssembly1");
|
||||
CodeReloadRegistry.RegisterMethodOverride(method1, attribute1, typeof(TestRegistryClass));
|
||||
|
||||
var method2 = typeof(TestRegistryClass2).GetMethod("AnotherTestMethod");
|
||||
var attribute2 = method2.GetCustomAttribute<CodeReloadOverrideMethodAttribute>();
|
||||
CodeReloadRegistry.RegisterCodeReloadType(typeof(TestRegistryClass2), "TestAssembly2");
|
||||
CodeReloadRegistry.RegisterMethodOverride(method2, attribute2, typeof(TestRegistryClass2));
|
||||
|
||||
var finalStats = CodeReloadRegistry.GetStats();
|
||||
|
||||
Assert.AreEqual(2, finalStats.ActiveOverrideCount, "Should have 2 active overrides");
|
||||
Assert.AreEqual(2, finalStats.LoadedTypeCount, "Should have 2 loaded types");
|
||||
Assert.AreEqual(2, finalStats.ReloadableMethodCount, "Should have 2 reloadable methods");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClearAllForTesting_ResetsStats()
|
||||
{
|
||||
// Register target method as reloadable first (this was missing!)
|
||||
var targetMethod = typeof(MockTargetClass1).GetMethod("TargetMethod1");
|
||||
var reloadableAttr = new CodeReloadAttribute { Id = "TargetClass1.TargetMethod1" };
|
||||
CodeReloadRegistry.RegisterReloadableMethod(targetMethod, reloadableAttr);
|
||||
|
||||
// Register something first
|
||||
var method = typeof(TestRegistryClass).GetMethod("TestMethod");
|
||||
var attribute = method.GetCustomAttribute<CodeReloadOverrideMethodAttribute>();
|
||||
CodeReloadRegistry.RegisterCodeReloadType(typeof(TestRegistryClass), "TestAssembly");
|
||||
CodeReloadRegistry.RegisterMethodOverride(method, attribute, typeof(TestRegistryClass));
|
||||
|
||||
var statsBeforeClear = CodeReloadRegistry.GetStats();
|
||||
Assert.Greater(statsBeforeClear.ActiveOverrideCount, 0, "Should have active overrides before clear");
|
||||
Assert.Greater(statsBeforeClear.ReloadableMethodCount, 0, "Should have reloadable methods before clear");
|
||||
|
||||
// Clear and verify
|
||||
CodeReloadRegistry.ClearAllForTesting();
|
||||
|
||||
var statsAfterClear = CodeReloadRegistry.GetStats();
|
||||
Assert.AreEqual(0, statsAfterClear.ActiveOverrideCount, "Should have 0 active overrides after clear");
|
||||
Assert.AreEqual(0, statsAfterClear.LoadedTypeCount, "Should have 0 loaded types after clear");
|
||||
Assert.AreEqual(0, statsAfterClear.ReloadableMethodCount, "Should have 0 reloadable methods after clear");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test class with CodeReloadMethod attribute for registry testing.
|
||||
/// </summary>
|
||||
public static class TestRegistryClass
|
||||
{
|
||||
[CodeReloadOverrideMethod("TargetClass1.TargetMethod1")]
|
||||
public static void TestMethod(MockTargetClass1 instance)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Second test class for multi-registration testing.
|
||||
/// </summary>
|
||||
public static class TestRegistryClass2
|
||||
{
|
||||
[CodeReloadOverrideMethod("TargetClass2.TargetMethod2")]
|
||||
public static void AnotherTestMethod(MockTargetClass2 instance)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock target class 1 for testing reloadable method registration.
|
||||
/// </summary>
|
||||
public class MockTargetClass1
|
||||
{
|
||||
public int value = 42;
|
||||
|
||||
public void TargetMethod1()
|
||||
{
|
||||
value = 123;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock target class 2 for testing reloadable method registration.
|
||||
/// </summary>
|
||||
public class MockTargetClass2
|
||||
{
|
||||
public string text = "original";
|
||||
|
||||
public void TargetMethod2()
|
||||
{
|
||||
text = "modified";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 522caacf8c1467a44b3f4f93157f373a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,259 @@
|
||||
using System.Collections;
|
||||
using System.IO;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.CodeReload;
|
||||
using Unity.Pipeline.Runtime.Commands;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Probe with method-level [CodeReload] STATIC methods. The weaver injects the same dispatch
|
||||
/// prologue as for instance methods, passing null as the instance argument; the override
|
||||
/// keeps the leading instance parameter and receives null.
|
||||
/// </summary>
|
||||
public class StaticReloadProbe
|
||||
{
|
||||
public static int baseline;
|
||||
public static int hotValue;
|
||||
public int instanceValue;
|
||||
|
||||
[CodeReload]
|
||||
public static void Compute()
|
||||
{
|
||||
baseline++; // original body
|
||||
}
|
||||
|
||||
[CodeReload]
|
||||
public static void Mark(StaticReloadProbe target)
|
||||
{
|
||||
target.instanceValue = 1; // original body
|
||||
}
|
||||
|
||||
[CodeReload]
|
||||
public static IEnumerator Countdown(StaticReloadProbe target)
|
||||
{
|
||||
target.instanceValue = -1; // original body
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
|
||||
static class StaticOverrides
|
||||
{
|
||||
[CodeReloadOverrideMethod("StaticReloadProbe.Compute")]
|
||||
public static void Compute(StaticReloadProbe instance) // instance arrives null
|
||||
{
|
||||
StaticReloadProbe.hotValue++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Static method code reload: woven dispatch with a null instance, end-to-end reload_file on
|
||||
/// the compiled (Assembly.Load) and interpreter (IlInterpreter) backends, and the static +
|
||||
/// coroutine combination.
|
||||
/// </summary>
|
||||
class CodeReloadStaticTests
|
||||
{
|
||||
[SetUp]
|
||||
public void Setup()
|
||||
{
|
||||
CodeReloadRegistry.ClearAllForTesting();
|
||||
StaticReloadProbe.baseline = 0;
|
||||
StaticReloadProbe.hotValue = 0;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => CodeReloadRegistry.ClearAllForTesting();
|
||||
|
||||
static void RegisterProbeMethod(string name) =>
|
||||
CodeReloadRegistry.RegisterReloadableMethod(
|
||||
typeof(StaticReloadProbe).GetMethod(name),
|
||||
new CodeReloadAttribute());
|
||||
|
||||
[Test]
|
||||
public void WovenDispatch_StaticMethod_RoutesToOverride_WithNullInstance()
|
||||
{
|
||||
RegisterProbeMethod(nameof(StaticReloadProbe.Compute));
|
||||
|
||||
var registered = CodeReloadRegistry.RegisterMethodOverride(
|
||||
typeof(StaticOverrides).GetMethod(nameof(StaticOverrides.Compute)),
|
||||
new CodeReloadOverrideMethodAttribute("StaticReloadProbe.Compute"),
|
||||
typeof(StaticOverrides),
|
||||
out var reason);
|
||||
Assert.IsTrue(registered, reason);
|
||||
|
||||
StaticReloadProbe.Compute();
|
||||
|
||||
Assert.AreEqual(0, StaticReloadProbe.baseline, "Original body must not run while an override is active");
|
||||
Assert.AreEqual(1, StaticReloadProbe.hotValue, "Override should have run (with a null instance)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReloadFileCompiled_StaticMethod_AppliesEdit()
|
||||
{
|
||||
RegisterProbeMethod(nameof(StaticReloadProbe.Compute));
|
||||
|
||||
var editedSource = @"
|
||||
using Unity.Pipeline.CodeReload;
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
public class StaticReloadProbe
|
||||
{
|
||||
public static int baseline;
|
||||
public static int hotValue;
|
||||
|
||||
[CodeReload]
|
||||
public static void Compute()
|
||||
{
|
||||
hotValue = 99;
|
||||
}
|
||||
}
|
||||
}";
|
||||
var path = Path.Combine(Application.temporaryCachePath, "StaticReloadProbe_edit.cs");
|
||||
File.WriteAllText(path, editedSource);
|
||||
|
||||
try
|
||||
{
|
||||
var response = CodeReloadCommands.ReloadFile(path);
|
||||
Assert.IsTrue(response.Success,
|
||||
$"reload_file should succeed for a static method. Error: {response.ErrorDetails}");
|
||||
|
||||
StaticReloadProbe.Compute();
|
||||
Assert.AreEqual(99, StaticReloadProbe.hotValue, "Edited static body should run via the woven dispatch");
|
||||
Assert.AreEqual(0, StaticReloadProbe.baseline, "Original body must not run");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReloadFileInterpreter_StaticMethod_WritesThroughParameter()
|
||||
{
|
||||
RegisterProbeMethod(nameof(StaticReloadProbe.Mark));
|
||||
|
||||
var editedSource = @"
|
||||
using Unity.Pipeline.CodeReload;
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
public class StaticReloadProbe
|
||||
{
|
||||
public int instanceValue;
|
||||
|
||||
[CodeReload]
|
||||
public static void Mark(StaticReloadProbe target)
|
||||
{
|
||||
target.instanceValue = 42;
|
||||
}
|
||||
}
|
||||
}";
|
||||
var path = Path.Combine(Application.temporaryCachePath, "StaticReloadProbe_mark_edit.cs");
|
||||
File.WriteAllText(path, editedSource);
|
||||
|
||||
try
|
||||
{
|
||||
var response = CodeReloadCommands.ReloadFileEditorInterpreter(path);
|
||||
Assert.IsTrue(response.Success,
|
||||
$"reload_file_editor_interpreter should succeed for a static method. Error: {response.ErrorDetails}");
|
||||
|
||||
var probe = new StaticReloadProbe();
|
||||
StaticReloadProbe.Mark(probe);
|
||||
Assert.AreEqual(42, probe.instanceValue,
|
||||
"Edited static body should run via the interpreter dispatch (null instance, real args)");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReloadFileInterpreter_StaticMethod_WritesStaticField()
|
||||
{
|
||||
RegisterProbeMethod(nameof(StaticReloadProbe.Compute));
|
||||
|
||||
var editedSource = @"
|
||||
using Unity.Pipeline.CodeReload;
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
public class StaticReloadProbe
|
||||
{
|
||||
public static int baseline;
|
||||
public static int hotValue;
|
||||
|
||||
[CodeReload]
|
||||
public static void Compute()
|
||||
{
|
||||
hotValue = 77;
|
||||
}
|
||||
}
|
||||
}";
|
||||
var path = Path.Combine(Application.temporaryCachePath, "StaticReloadProbe_sfield_edit.cs");
|
||||
File.WriteAllText(path, editedSource);
|
||||
|
||||
try
|
||||
{
|
||||
var response = CodeReloadCommands.ReloadFileEditorInterpreter(path);
|
||||
Assert.IsTrue(response.Success,
|
||||
$"reload_file_editor_interpreter should succeed. Error: {response.ErrorDetails}");
|
||||
|
||||
StaticReloadProbe.Compute();
|
||||
Assert.AreEqual(77, StaticReloadProbe.hotValue,
|
||||
"Edited static body should write the host static field via the interpreter");
|
||||
Assert.AreEqual(0, StaticReloadProbe.baseline, "Original body must not run");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReloadFileInterpreter_StaticCoroutine_RunsAsInterpretedStateMachine()
|
||||
{
|
||||
RegisterProbeMethod(nameof(StaticReloadProbe.Countdown));
|
||||
|
||||
var editedSource = @"
|
||||
using System.Collections;
|
||||
using Unity.Pipeline.CodeReload;
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
public class StaticReloadProbe
|
||||
{
|
||||
public int instanceValue;
|
||||
|
||||
[CodeReload]
|
||||
public static IEnumerator Countdown(StaticReloadProbe target)
|
||||
{
|
||||
target.instanceValue = 1;
|
||||
yield return null;
|
||||
target.instanceValue = 2;
|
||||
}
|
||||
}
|
||||
}";
|
||||
var path = Path.Combine(Application.temporaryCachePath, "StaticReloadProbe_coro_edit.cs");
|
||||
File.WriteAllText(path, editedSource);
|
||||
|
||||
try
|
||||
{
|
||||
var response = CodeReloadCommands.ReloadFileEditorInterpreter(path);
|
||||
Assert.IsTrue(response.Success,
|
||||
$"reload_file_editor_interpreter should succeed for a static coroutine. Error: {response.ErrorDetails}");
|
||||
|
||||
var probe = new StaticReloadProbe();
|
||||
var routine = StaticReloadProbe.Countdown(probe); // woven dispatch → interpreter → bridge
|
||||
|
||||
Assert.IsTrue(routine.MoveNext());
|
||||
Assert.AreEqual(1, probe.instanceValue);
|
||||
Assert.IsFalse(routine.MoveNext());
|
||||
Assert.AreEqual(2, probe.instanceValue,
|
||||
"Static coroutine edit should run as an interpreted state machine");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 972068c2e04442ccb2b86322d054f0cf
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,136 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.CodeReload;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Simple verification tests for code reload infrastructure.
|
||||
/// These tests verify the core functionality works correctly in isolation.
|
||||
/// </summary>
|
||||
class CodeReloadVerificationTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
// Ensure clean state for each test
|
||||
CodeReloadRegistry.ClearAllForTesting();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
// Clean up after each test
|
||||
CodeReloadRegistry.ClearAllForTesting();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CodeReloadRegistry_EmptyState_HasZeroStats()
|
||||
{
|
||||
// Act
|
||||
var stats = CodeReloadRegistry.GetStats();
|
||||
|
||||
// Assert
|
||||
Assert.That(stats.ReloadableMethodCount, Is.EqualTo(0));
|
||||
Assert.That(stats.ActiveOverrideCount, Is.EqualTo(0));
|
||||
Assert.That(stats.LoadedTypeCount, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CodeReloadRegistry_RegisterSingleMethod_CorrectCount()
|
||||
{
|
||||
// Arrange
|
||||
var testType = typeof(SimpleTestClass);
|
||||
var testMethod = testType.GetMethod("TestMethod");
|
||||
var attribute = new CodeReloadAttribute();
|
||||
|
||||
// Act
|
||||
CodeReloadRegistry.RegisterReloadableMethod(testMethod, attribute);
|
||||
|
||||
// Assert
|
||||
var stats = CodeReloadRegistry.GetStats();
|
||||
Assert.That(stats.ReloadableMethodCount, Is.EqualTo(1));
|
||||
Assert.That(stats.ReloadableMethodIds.Count, Is.EqualTo(1));
|
||||
Assert.That(stats.ReloadableMethodIds.Contains("SimpleTestClass.TestMethod"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CodeReloadRegistry_RegisterMultipleMethods_CorrectCount()
|
||||
{
|
||||
// Arrange
|
||||
var testType = typeof(SimpleTestClass);
|
||||
var method1 = testType.GetMethod("TestMethod");
|
||||
var method2 = testType.GetMethod("AnotherTestMethod");
|
||||
|
||||
// Act
|
||||
CodeReloadRegistry.RegisterReloadableMethod(method1, new CodeReloadAttribute());
|
||||
CodeReloadRegistry.RegisterReloadableMethod(method2, new CodeReloadAttribute { Id = "CustomId" });
|
||||
|
||||
// Assert
|
||||
var stats = CodeReloadRegistry.GetStats();
|
||||
Assert.That(stats.ReloadableMethodCount, Is.EqualTo(2));
|
||||
Assert.That(stats.ReloadableMethodIds.Count, Is.EqualTo(2));
|
||||
Assert.That(stats.ReloadableMethodIds.Contains("SimpleTestClass.TestMethod"));
|
||||
Assert.That(stats.ReloadableMethodIds.Contains("CustomId"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CodeReloadRegistry_TryInvokeWithoutOverride_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var testType = typeof(SimpleTestClass);
|
||||
var testMethod = testType.GetMethod("TestMethod");
|
||||
CodeReloadRegistry.RegisterReloadableMethod(testMethod, new CodeReloadAttribute());
|
||||
|
||||
var testInstance = new SimpleTestClass();
|
||||
|
||||
// Act
|
||||
var result = CodeReloadRegistry.TryInvokeCodeReload("SimpleTestClass.TestMethod", testInstance);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CodeReloadRegistry_ClearForTesting_RemovesAllState()
|
||||
{
|
||||
// Arrange - add some state
|
||||
var testType = typeof(SimpleTestClass);
|
||||
var testMethod = testType.GetMethod("TestMethod");
|
||||
CodeReloadRegistry.RegisterReloadableMethod(testMethod, new CodeReloadAttribute());
|
||||
|
||||
// Verify state exists
|
||||
var statsBefore = CodeReloadRegistry.GetStats();
|
||||
Assert.That(statsBefore.ReloadableMethodCount, Is.EqualTo(1));
|
||||
|
||||
// Act
|
||||
CodeReloadRegistry.ClearAllForTesting();
|
||||
|
||||
// Assert
|
||||
var statsAfter = CodeReloadRegistry.GetStats();
|
||||
Assert.That(statsAfter.ReloadableMethodCount, Is.EqualTo(0));
|
||||
Assert.That(statsAfter.ActiveOverrideCount, Is.EqualTo(0));
|
||||
Assert.That(statsAfter.LoadedTypeCount, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple test class for code reload testing.
|
||||
/// </summary>
|
||||
public class SimpleTestClass
|
||||
{
|
||||
public int value;
|
||||
|
||||
public void TestMethod()
|
||||
{
|
||||
value = 42;
|
||||
}
|
||||
|
||||
public int AnotherTestMethod(int input)
|
||||
{
|
||||
return input * 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d32ede884c994bb4b9f1b1bf6053697a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,732 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline;
|
||||
using Unity.Pipeline.Commands;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Unit tests for CommandLineBinder - binds argv tokens to a command's declared
|
||||
/// parameters, accumulating every defect rather than bailing on the first.
|
||||
///
|
||||
/// The load-bearing invariant is CONVERGENCE: the JObject produced from a command line
|
||||
/// must be indistinguishable from the structured <c>parameters</c> object a client would
|
||||
/// have sent, so everything from ExtractCommandParameters downwards has exactly one code
|
||||
/// path. That is what
|
||||
/// <see cref="TryBind_PositionalAndFlagAndStructured_AllProduceTheSameObject"/> pins.
|
||||
/// </summary>
|
||||
class CommandLineBinderTests
|
||||
{
|
||||
// The binder reads only Name and Parameters, but CommandInfo requires a non-null
|
||||
// MethodInfo. Any method will do; this one exists solely to satisfy that.
|
||||
private static void Placeholder() { }
|
||||
|
||||
private static readonly MethodInfo s_Placeholder =
|
||||
typeof(CommandLineBinderTests).GetMethod(nameof(Placeholder),
|
||||
BindingFlags.NonPublic | BindingFlags.Static);
|
||||
|
||||
private static CommandParameterInfo Param(string name, System.Type type, bool required = false,
|
||||
object defaultValue = null) =>
|
||||
new CommandParameterInfo(name, $"{name} description", required, type, defaultValue);
|
||||
|
||||
private static CommandInfo Command(string name, params CommandParameterInfo[] parameters) =>
|
||||
new CommandInfo(name, $"{name} description", false, s_Placeholder, parameters);
|
||||
|
||||
/// <summary>log_editor: one required string, the simplest real shape.</summary>
|
||||
private static CommandInfo LogEditor() =>
|
||||
Command("log_editor", Param("message", typeof(string), required: true));
|
||||
|
||||
private static JObject Bind(CommandInfo command, params string[] args)
|
||||
{
|
||||
Assert.IsTrue(CommandLineBinder.TryBind(command, args, out var parameters, out var problems),
|
||||
$"Expected a clean bind, got problems: {Describe(problems)}");
|
||||
Assert.IsEmpty(problems, "A successful bind must report no problems");
|
||||
return parameters;
|
||||
}
|
||||
|
||||
private static List<ArgProblem> BindProblems(CommandInfo command, params string[] args)
|
||||
{
|
||||
Assert.IsFalse(CommandLineBinder.TryBind(command, args, out _, out var problems),
|
||||
"Expected the bind to be rejected");
|
||||
Assert.IsNotEmpty(problems, "A failed bind must report at least one problem");
|
||||
return problems;
|
||||
}
|
||||
|
||||
private static string Describe(List<ArgProblem> problems)
|
||||
{
|
||||
if (problems == null) return "<null>";
|
||||
var parts = new List<string>();
|
||||
foreach (var p in problems)
|
||||
parts.Add($"{p.Kind}(name={p.Name}, token={p.Token}, suggestion={p.Suggestion})");
|
||||
return string.Join("; ", parts);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- convergence
|
||||
|
||||
[Test]
|
||||
public void TryBind_PositionalAndFlagAndStructured_AllProduceTheSameObject()
|
||||
{
|
||||
var expected = new JObject { ["message"] = "hi" };
|
||||
|
||||
Assert.IsTrue(JToken.DeepEquals(expected, Bind(LogEditor(), "hi")),
|
||||
"positional form must match the structured equivalent");
|
||||
Assert.IsTrue(JToken.DeepEquals(expected, Bind(LogEditor(), "--message", "hi")),
|
||||
"--key value form must match the structured equivalent");
|
||||
Assert.IsTrue(JToken.DeepEquals(expected, Bind(LogEditor(), "--message=hi")),
|
||||
"--key=value form must match the structured equivalent");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_StoresStringValues_SoTheExecutorSeesOneShape()
|
||||
{
|
||||
// Values are stored as string JValues, never pre-converted. ConvertParameterToken
|
||||
// does the widening downstream, identically for both request forms.
|
||||
var command = Command("test_types", Param("count", typeof(int), defaultValue: 1));
|
||||
|
||||
var bound = Bind(command, "--count", "5");
|
||||
|
||||
Assert.AreEqual(JTokenType.String, bound["count"].Type,
|
||||
"the binder must not pre-convert; coercion happens in the executor");
|
||||
Assert.AreEqual("5", bound["count"].Value<string>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_NoArguments_ProducesEmptyObject()
|
||||
{
|
||||
var bound = Bind(Command("test_tagged"));
|
||||
Assert.AreEqual(0, bound.Count);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- flag classification
|
||||
|
||||
[Test]
|
||||
public void TryBind_EmptyStringValue_IsTheEmptyStringNotTrue()
|
||||
{
|
||||
// Conflating "no next token" with "empty next token" made `--message ""`
|
||||
// send boolean true. Absence and emptiness are different.
|
||||
var bound = Bind(LogEditor(), "--message", "");
|
||||
|
||||
Assert.AreEqual(JTokenType.String, bound["message"].Type);
|
||||
Assert.AreEqual(string.Empty, bound["message"].Value<string>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_FlagFollowedBySingleDashToken_ConsumesItAsTheValue()
|
||||
{
|
||||
// `--message -foo` is the escape hatch for values that look like flags.
|
||||
// The follower is consumed unless it starts with "--".
|
||||
var bound = Bind(LogEditor(), "--message", "-foo");
|
||||
Assert.AreEqual("-foo", bound["message"].Value<string>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_DuplicateFlag_ReportsDuplicate()
|
||||
{
|
||||
var problems = BindProblems(LogEditor(), "--message", "a", "--message", "b");
|
||||
|
||||
Assert.AreEqual(1, problems.Count);
|
||||
Assert.AreEqual(ArgProblemKind.Duplicate, problems[0].Kind);
|
||||
Assert.AreEqual("message", problems[0].Name);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_FlagWithEmptyName_ReportsEmptyName()
|
||||
{
|
||||
var problems = BindProblems(LogEditor(), "--=oops");
|
||||
|
||||
Assert.AreEqual(ArgProblemKind.EmptyName, problems[0].Kind);
|
||||
Assert.AreEqual("--=oops", problems[0].Token);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_FlagWithTrailingEquals_ReportsEmptyValue()
|
||||
{
|
||||
var problems = BindProblems(LogEditor(), "--message=");
|
||||
|
||||
Assert.AreEqual(ArgProblemKind.EmptyValue, problems[0].Kind);
|
||||
Assert.AreEqual("message", problems[0].Name);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_UnknownFlag_ReportsUnknownNameWithDidYouMean()
|
||||
{
|
||||
var problems = BindProblems(LogEditor(), "--mesage", "hi");
|
||||
|
||||
Assert.AreEqual(1, problems.Count);
|
||||
Assert.AreEqual(ArgProblemKind.UnknownName, problems[0].Kind);
|
||||
Assert.AreEqual("mesage", problems[0].Name);
|
||||
Assert.AreEqual("message", problems[0].Suggestion, "a one-character typo must suggest");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_UnknownFlagUnlikeAnyParameter_ReportsNoSuggestion()
|
||||
{
|
||||
var problems = BindProblems(LogEditor(), "--zzzzzzzzz", "hi");
|
||||
|
||||
Assert.AreEqual(ArgProblemKind.UnknownName, problems[0].Kind);
|
||||
Assert.IsNull(problems[0].Suggestion, "an unrelated name must not get a spurious suggestion");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_AccumulatesEveryProblem_RatherThanBailingOnTheFirst()
|
||||
{
|
||||
var problems = BindProblems(LogEditor(), "--nope", "x", "--=bad");
|
||||
|
||||
Assert.AreEqual(2, problems.Count, $"expected both problems, got: {Describe(problems)}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- positional slots
|
||||
|
||||
[Test]
|
||||
public void TryBind_Positionals_FillRequiredThenOptionalInDeclarationOrder()
|
||||
{
|
||||
// Declaration order is: optional 'first', required 'second'. Required wins the
|
||||
// first positional slot regardless of where it is declared.
|
||||
var command = Command("two_slots",
|
||||
Param("first", typeof(string)),
|
||||
Param("second", typeof(string), required: true));
|
||||
|
||||
var bound = Bind(command, "A", "B");
|
||||
|
||||
Assert.AreEqual("A", bound["second"].Value<string>(), "required slot fills first");
|
||||
Assert.AreEqual("B", bound["first"].Value<string>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>find_assets</c> shape: every parameter optional, so there is no required slot
|
||||
/// to anchor on. The first positional must still land on the FIRST DECLARED parameter.
|
||||
///
|
||||
/// A client that binds against a fetched catalog has a failure mode here that this
|
||||
/// design does not: lose the schema and there is nothing left to order by, so a
|
||||
/// positional has to be guessed at. Binding against the reflected signature cannot
|
||||
/// degrade that way — the declaration is always present.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void TryBind_AllOptionalParameters_StillFillInDeclarationOrder()
|
||||
{
|
||||
var findAssets = Command("find_assets",
|
||||
Param("type", typeof(string)),
|
||||
Param("name", typeof(string)),
|
||||
Param("label", typeof(string)),
|
||||
Param("search_in", typeof(string)),
|
||||
Param("limit", typeof(int), defaultValue: 200));
|
||||
|
||||
var one = Bind(findAssets, "Texture2D");
|
||||
Assert.IsTrue(JToken.DeepEquals(new JObject { ["type"] = "Texture2D" }, one),
|
||||
"a single positional binds to the first declared parameter and nothing else");
|
||||
|
||||
var two = Bind(findAssets, "Texture2D", "Grass");
|
||||
Assert.AreEqual("Texture2D", two["type"].Value<string>());
|
||||
Assert.AreEqual("Grass", two["name"].Value<string>());
|
||||
Assert.IsNull(two["label"], "unfilled optional slots stay absent, not null");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_AllOptionalParameters_FlagAndPositionalCombine()
|
||||
{
|
||||
// `find_assets Texture2D --limit 5`: the flag takes its own slot, and the positional
|
||||
// still lands on `type` rather than being pushed along by it.
|
||||
var findAssets = Command("find_assets",
|
||||
Param("type", typeof(string)),
|
||||
Param("name", typeof(string)),
|
||||
Param("limit", typeof(int), defaultValue: 200));
|
||||
|
||||
var bound = Bind(findAssets, "Texture2D", "--limit", "5");
|
||||
|
||||
Assert.AreEqual("Texture2D", bound["type"].Value<string>());
|
||||
Assert.AreEqual("5", bound["limit"].Value<string>());
|
||||
Assert.IsNull(bound["name"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_SlotAlreadyFilledByFlag_IsSkippedNotOverwritten()
|
||||
{
|
||||
var command = Command("two_slots",
|
||||
Param("a", typeof(string), required: true),
|
||||
Param("b", typeof(string)));
|
||||
|
||||
var bound = Bind(command, "--a", "viaFlag", "viaPositional");
|
||||
|
||||
Assert.AreEqual("viaFlag", bound["a"].Value<string>(), "the flag must win its own slot");
|
||||
Assert.AreEqual("viaPositional", bound["b"].Value<string>(), "the positional moves to the next free slot");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_MorePositionalsThanSlots_ReportsExcessWithCapacityAndGiven()
|
||||
{
|
||||
var problems = BindProblems(LogEditor(), "one", "two", "three");
|
||||
|
||||
Assert.AreEqual(ArgProblemKind.ExcessPositional, problems[0].Kind);
|
||||
Assert.AreEqual(1, problems[0].Capacity, "log_editor declares one slot");
|
||||
Assert.AreEqual(3, problems[0].Given);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_CommandWithNoParameters_RejectsAnyPositional()
|
||||
{
|
||||
var problems = BindProblems(Command("test_tagged"), "stray");
|
||||
|
||||
Assert.AreEqual(ArgProblemKind.ExcessPositional, problems[0].Kind);
|
||||
Assert.AreEqual(0, problems[0].Capacity);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_SingleDashInPositionalPosition_IsReported()
|
||||
{
|
||||
var problems = BindProblems(LogEditor(), "-foo");
|
||||
|
||||
Assert.AreEqual(ArgProblemKind.SingleDash, problems[0].Kind);
|
||||
Assert.AreEqual("-foo", problems[0].Token);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_NegativeNumberInPositionalPosition_IsNotASingleDashProblem()
|
||||
{
|
||||
// -5 and -1.5 are values, not malformed flags.
|
||||
var command = Command("takes_number", Param("value", typeof(string), required: true));
|
||||
|
||||
Assert.AreEqual("-5", Bind(command, "-5")["value"].Value<string>());
|
||||
Assert.AreEqual("-1.5", Bind(command, "-1.5")["value"].Value<string>());
|
||||
Assert.AreEqual("-", Bind(command, "-")["value"].Value<string>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_SingleDashRule_IsLetterAnchoredExactlyLikeTheCli()
|
||||
{
|
||||
// The CLI keeps this check client-side (offline, no Editor boot), which is only safe
|
||||
// while its rule is a strict SUBSET of ours. Its rule is char.IsAsciiLetter(token[1]),
|
||||
// so '-5x' must be a VALUE here too -- a looser "not a number" test would reject it
|
||||
// server-side after the CLI had already waved it through.
|
||||
var command = Command("takes_value", Param("value", typeof(string), required: true));
|
||||
|
||||
Assert.AreEqual("-5x", Bind(command, "-5x")["value"].Value<string>());
|
||||
Assert.AreEqual(ArgProblemKind.SingleDash, BindProblems(command, "-foo")[0].Kind);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_BareAssignmentNamingAParameter_IsReportedButStillCountsAsPositional()
|
||||
{
|
||||
// Rejects `create_folder path=Assets/Foo` (a misremembered flag syntax) while
|
||||
// leaving `eval "obj=null;"` alone -- see the eval test below.
|
||||
var command = Command("create_folder", Param("path", typeof(string), required: true));
|
||||
|
||||
var problems = BindProblems(command, "path=Assets/Foo");
|
||||
|
||||
Assert.AreEqual(ArgProblemKind.BareAssignment, problems[0].Kind);
|
||||
Assert.AreEqual("path=Assets/Foo", problems[0].Token);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_EvalCodeContainingAnAssignment_BindsCleanly()
|
||||
{
|
||||
// THE case the bare-assignment heuristic must not break: 'obj' is not a
|
||||
// declared parameter of eval, so this is code, not a misspelled flag.
|
||||
var eval = Command("eval",
|
||||
Param("code", typeof(string), required: true),
|
||||
Param("timeout", typeof(int)));
|
||||
|
||||
var bound = Bind(eval, "obj=null;");
|
||||
|
||||
Assert.AreEqual("obj=null;", bound["code"].Value<string>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_DoubleDash_EndsFlagParsingAndExemptsTheRest()
|
||||
{
|
||||
var command = Command("two_slots",
|
||||
Param("a", typeof(string), required: true),
|
||||
Param("b", typeof(string)));
|
||||
|
||||
// After --, `-x` is a value and `a=1` is not a bare assignment.
|
||||
var bound = Bind(command, "--", "-x", "a=1");
|
||||
|
||||
Assert.AreEqual("-x", bound["a"].Value<string>());
|
||||
Assert.AreEqual("a=1", bound["b"].Value<string>());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- boundaries
|
||||
|
||||
[Test]
|
||||
public void TryBind_MissingRequiredParameter_IsNotABinderProblem()
|
||||
{
|
||||
// Required-parameter checking stays in ValidateCommandParameters so the message
|
||||
// and error type are byte-identical across both request forms.
|
||||
var bound = Bind(LogEditor());
|
||||
|
||||
Assert.AreEqual(0, bound.Count);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- coercion (dry run)
|
||||
|
||||
/// <summary>Declared locally so the binder's enum handling is testable without
|
||||
/// touching DummyCommands, which other suites share.</summary>
|
||||
public enum SampleMode { Editor, Player, Headless }
|
||||
|
||||
[Test]
|
||||
public void TryBind_IntParameterGivenNonNumericToken_ReportsTypeMismatch()
|
||||
{
|
||||
// Without this, ConvertParameterToken throws inside the executor and the request
|
||||
// dies as a 500-ish "Command Execution Failed" instead of a usage error.
|
||||
var command = Command("test_types", Param("count", typeof(int), defaultValue: 1));
|
||||
|
||||
var problems = BindProblems(command, "--count", "abc");
|
||||
|
||||
Assert.AreEqual(1, problems.Count);
|
||||
Assert.AreEqual(ArgProblemKind.TypeMismatch, problems[0].Kind);
|
||||
Assert.AreEqual("count", problems[0].Name);
|
||||
Assert.AreEqual("abc", problems[0].Token);
|
||||
Assert.AreEqual("Int32", problems[0].ExpectedType);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_IntParameterGivenNumericToken_BindsCleanly()
|
||||
{
|
||||
var command = Command("test_types", Param("count", typeof(int), defaultValue: 1));
|
||||
|
||||
Assert.AreEqual("5", Bind(command, "--count", "5")["count"].Value<string>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_FloatParameterGivenDecimalToken_BindsCleanly()
|
||||
{
|
||||
var command = Command("test_types", Param("factor", typeof(float), defaultValue: 1.0f));
|
||||
|
||||
Assert.AreEqual("1.5", Bind(command, "--factor", "1.5")["factor"].Value<string>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_StringParameterGivenLeadingZeros_KeepsThemAsText()
|
||||
{
|
||||
// Regression guard, not a driver: this already holds because the binder stores
|
||||
// strings. It is the semantic FIX over the CLI's old shape-guessing ParseValue,
|
||||
// which turned 007 into the number 7 even for a declared string parameter.
|
||||
var command = Command("reload_file", Param("path", typeof(string), required: true));
|
||||
|
||||
Assert.AreEqual("007", Bind(command, "007")["path"].Value<string>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_BoolParameterAsBareFlag_BindsTrue()
|
||||
{
|
||||
var command = Command("test_types", Param("enabled", typeof(bool), defaultValue: false));
|
||||
|
||||
var bound = Bind(command, "--enabled");
|
||||
|
||||
Assert.IsTrue(JToken.DeepEquals(new JObject { ["enabled"] = true }, bound),
|
||||
"a bare flag on a bool must match the structured {\"enabled\":true}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_NonBoolParameterAsBareFlag_IsReportedNotCoercedToTheStringTrue()
|
||||
{
|
||||
// JValue(true).ToObject<string>() silently yields "True". Catching it here is what
|
||||
// stops `log_editor --message` from logging the word True.
|
||||
var problems = BindProblems(LogEditor(), "--message");
|
||||
|
||||
Assert.AreEqual(1, problems.Count);
|
||||
Assert.AreEqual(ArgProblemKind.EmptyValue, problems[0].Kind,
|
||||
"the user's mistake is a missing value, so EmptyValue reads better than TypeMismatch");
|
||||
Assert.AreEqual("message", problems[0].Name);
|
||||
}
|
||||
|
||||
// Enum binding defers entirely to the executor's own converter, so these cases ARE the
|
||||
// parity contract: whatever the structured path accepts, the raw path must accept too.
|
||||
[TestCase(typeof(SampleMode), "player", TestName = "TryBind_EnumName_IsAccepted")]
|
||||
[TestCase(typeof(Severity), "warning", TestName = "TryBind_EnumName_IsAcceptedCaseInsensitively")]
|
||||
// A [Flags] enum takes a comma-separated combination natively. A dry run that reimplemented
|
||||
// name matching found no single name equal to "Info,Warning" and rejected a request the
|
||||
// structured path accepts.
|
||||
[TestCase(typeof(Channels), "Info,Warning", TestName = "TryBind_FlagsEnumCombination_IsAccepted")]
|
||||
public void TryBind_EnumValueTheConverterAccepts_BindsTheTokenVerbatim(
|
||||
System.Type parameterType, string token)
|
||||
{
|
||||
var command = Command("log", Param("level", parameterType, required: true));
|
||||
|
||||
// Stored as the token's own text, like every other declared type: the executor
|
||||
// re-converts from the same string, so binding and execution cannot diverge.
|
||||
Assert.IsTrue(JToken.DeepEquals(new JObject { ["level"] = token }, Bind(command, token)));
|
||||
}
|
||||
|
||||
// The point of deferring to the converter is parity, not leniency. ExpectedType must name
|
||||
// the enum itself, never Nullable`1.
|
||||
[TestCase(typeof(SampleMode), "sideways", "SampleMode",
|
||||
TestName = "TryBind_UnknownEnumName_ReportsTypeMismatch")]
|
||||
[TestCase(typeof(Severity), "Verbose", "Severity",
|
||||
TestName = "TryBind_UnknownEnumName_NamesTheEnum")]
|
||||
[TestCase(typeof(Severity?), "Verbose", "Severity",
|
||||
TestName = "TryBind_UnknownEnumNameOnANullableEnum_NamesTheUnderlyingType")]
|
||||
public void TryBind_EnumValueTheConverterRejects_ReportsTypeMismatch(
|
||||
System.Type parameterType, string token, string expectedTypeName)
|
||||
{
|
||||
var command = Command("log", Param("level", parameterType, required: true));
|
||||
|
||||
var problems = BindProblems(command, token);
|
||||
|
||||
Assert.AreEqual(ArgProblemKind.TypeMismatch, problems[0].Kind);
|
||||
Assert.AreEqual("level", problems[0].Name);
|
||||
Assert.AreEqual(token, problems[0].Token);
|
||||
Assert.AreEqual(expectedTypeName, problems[0].ExpectedType);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_JsonObjectStringForStructuredParameter_BindsCleanlyAndStaysAString()
|
||||
{
|
||||
// The '{'-prefixed JSON re-parse guard in ConvertParameterToken must be exercised by
|
||||
// the dry run, and the dry run must NOT rewrite what we store -- the executor redoes
|
||||
// the conversion from the same string.
|
||||
var command = Command("batch", Param("operations", typeof(JObject), required: true));
|
||||
|
||||
var bound = Bind(command, "{\"a\":1}");
|
||||
|
||||
Assert.AreEqual(JTokenType.String, bound["operations"].Type);
|
||||
Assert.AreEqual("{\"a\":1}", bound["operations"].Value<string>());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- untyped parameters (Risk #4)
|
||||
|
||||
// 14 parameters across 12 shipped commands declare JToken/JObject/JArray — e.g.
|
||||
// set_serialized_field.value, add_animator_parameter.defaultValue. For a DECLARED type the
|
||||
// token's shape is irrelevant (a string coerces to int identically), but an untyped
|
||||
// parameter has nothing to coerce toward, so the shape is the only signal there is. The CLI
|
||||
// used to guess it client-side; dropping that guess would silently turn a working
|
||||
// `--value 5` into the string "5". These cases reproduce the CLI's rules exactly.
|
||||
[TestCase("5", JTokenType.Integer, TestName = "TryBind_UntypedInteger_KeepsItsJsonType")]
|
||||
[TestCase("1.5", JTokenType.Float, TestName = "TryBind_UntypedFloat_KeepsItsJsonType")]
|
||||
[TestCase("-17", JTokenType.Integer, TestName = "TryBind_UntypedNegativeInteger_KeepsItsJsonType")]
|
||||
[TestCase("true", JTokenType.Boolean, TestName = "TryBind_UntypedBoolean_KeepsItsJsonType")]
|
||||
// The old rule lower-cased before comparing.
|
||||
[TestCase("TRUE", JTokenType.Boolean, TestName = "TryBind_UntypedUppercaseBoolean_KeepsItsJsonType")]
|
||||
// 07 is a number (digit-accumulated, no radix handling), 1.2.3 is not, a bare dash is not.
|
||||
[TestCase("07", JTokenType.Integer, TestName = "TryBind_UntypedLeadingZeroInteger_KeepsItsJsonType")]
|
||||
[TestCase("1.2.3", JTokenType.String, TestName = "TryBind_UntypedSecondDot_StaysAString")]
|
||||
[TestCase("-", JTokenType.String, TestName = "TryBind_UntypedBareDash_StaysAString")]
|
||||
[TestCase("Assets/Foo.prefab", JTokenType.String, TestName = "TryBind_UntypedAssetPath_StaysAString")]
|
||||
public void TryBind_ScalarForAnUntypedParameter_KeepsItsJsonType(
|
||||
string token, JTokenType expected)
|
||||
{
|
||||
var command = Command("set_serialized_field", Param("value", typeof(JToken), required: true));
|
||||
|
||||
Assert.AreEqual(expected, Bind(command, token)["value"].Type);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_UntypedLeadingZeroInteger_ParsesWithoutRadix()
|
||||
{
|
||||
// Shape alone is not the whole contract: 07 must become 7, not 8 (octal) or "07".
|
||||
var command = Command("set_serialized_field", Param("value", typeof(JToken), required: true));
|
||||
|
||||
Assert.AreEqual(7, Bind(command, "07")["value"].Value<int>());
|
||||
}
|
||||
|
||||
// The counterpart: a DECLARED type means the shape must NOT be guessed. This is the
|
||||
// semantic fix over the old client-side guess, which turned 007 into the number 7 even for
|
||||
// a declared string.
|
||||
[TestCase(typeof(string), "007", TestName = "TryBind_DeclaredStringGivenDigits_StaysAString")]
|
||||
[TestCase(typeof(int), "5", TestName = "TryBind_DeclaredIntGivenDigits_IsStillStoredAsAString")]
|
||||
public void TryBind_ScalarForATypedParameter_StaysAStringRegardlessOfShape(
|
||||
System.Type parameterType, string token)
|
||||
{
|
||||
var command = Command("reload_file", Param("value", parameterType, required: true));
|
||||
|
||||
Assert.AreEqual(JTokenType.String, Bind(command, token)["value"].Type);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_UnparseableJsonForStructuredParameter_ReportsTypeMismatch()
|
||||
{
|
||||
// The silent-drop class: ToObject returns NULL rather than throwing, so today the
|
||||
// parameter vanishes and the command runs with a default. That must be an error.
|
||||
var command = Command("batch", Param("operations", typeof(float[]), required: true));
|
||||
|
||||
var problems = BindProblems(command, "not-json-at-all");
|
||||
|
||||
Assert.AreEqual(ArgProblemKind.TypeMismatch, problems[0].Kind);
|
||||
Assert.AreEqual("operations", problems[0].Name);
|
||||
}
|
||||
|
||||
// ------------------------------------------------- accumulation across value defects
|
||||
|
||||
[Test]
|
||||
public void TryBind_DuplicateWhoseSecondUseIsEmpty_ReportsBothDefects()
|
||||
{
|
||||
// Accumulation is this class's contract: a user fixing a command line must see every
|
||||
// defect at once. The empty-value branch used to return before the duplicate check,
|
||||
// so resolving the reported EmptyValue left a still-unreported duplicate behind.
|
||||
var problems = BindProblems(LogEditor(), "--message", "hi", "--message=");
|
||||
|
||||
Assert.AreEqual(2, problems.Count, $"expected both defects, got: {Describe(problems)}");
|
||||
CollectionAssert.AreEquivalent(
|
||||
new[] { ArgProblemKind.Duplicate, ArgProblemKind.EmptyValue },
|
||||
new[] { problems[0].Kind, problems[1].Kind });
|
||||
}
|
||||
|
||||
// ------------------------------------------------- positional overflow classification
|
||||
|
||||
/// <summary>a is required, b is optional - two slots, so three values is one too many.</summary>
|
||||
private static CommandInfo TwoSlots() =>
|
||||
Command("two_slots",
|
||||
Param("a", typeof(string), required: true),
|
||||
Param("b", typeof(string)));
|
||||
|
||||
[Test]
|
||||
public void TryBind_OverflowPastASlotAFlagDidNotFill_IsExcessPositional()
|
||||
{
|
||||
// `--a X Y Z`: Y takes slot b, so Z overflows because the command has two slots and
|
||||
// three values were supplied - NOT because Z collided with the --a flag. Classifying
|
||||
// it as a conflict told the client something factually untrue about Z.
|
||||
var problems = BindProblems(TwoSlots(), "--a", "X", "Y", "Z");
|
||||
|
||||
Assert.AreEqual(1, problems.Count, Describe(problems));
|
||||
Assert.AreEqual(ArgProblemKind.ExcessPositional, problems[0].Kind);
|
||||
Assert.AreEqual("Z", problems[0].Token);
|
||||
Assert.AreEqual(1, problems[0].Capacity, "one slot was left free after --a took its own");
|
||||
Assert.AreEqual(2, problems[0].Given, "capacity and given must be commensurable: free slots vs positionals");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_PositionalWhoseOwnSlotAFlagFilled_IsAConflictNamingThatParameter()
|
||||
{
|
||||
// One slot, filled by --message, then a positional with nowhere to go. This IS the
|
||||
// conflict case, and the rendered message reads "<name> is already set by --<name>",
|
||||
// so Name has to be populated or the client prints an empty subject.
|
||||
var problems = BindProblems(LogEditor(), "--message", "hi", "orphan");
|
||||
|
||||
Assert.AreEqual(1, problems.Count, Describe(problems));
|
||||
Assert.AreEqual(ArgProblemKind.PositionalConflict, problems[0].Kind);
|
||||
Assert.AreEqual("message", problems[0].Name, "the conflicting parameter must be named");
|
||||
Assert.AreEqual("orphan", problems[0].Token);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_ExcessPositional_NamesTheFirstTokenThatOverflowed()
|
||||
{
|
||||
// The prose renders "... but 3 were given (starting at 'X')", so the token has to be
|
||||
// the first one with no slot, not the last one supplied.
|
||||
var problems = BindProblems(LogEditor(), "one", "two", "three");
|
||||
|
||||
Assert.AreEqual(1, problems.Count, Describe(problems));
|
||||
Assert.AreEqual(ArgProblemKind.ExcessPositional, problems[0].Kind);
|
||||
Assert.AreEqual("two", problems[0].Token, "'one' filled the only slot, so 'two' is the first excess");
|
||||
Assert.AreEqual(1, problems[0].Capacity);
|
||||
Assert.AreEqual(3, problems[0].Given);
|
||||
}
|
||||
|
||||
// ------------------------------------------------- bare assignment precision
|
||||
|
||||
/// <summary>eval: a required code string plus an optional integer timeout.</summary>
|
||||
private static CommandInfo Eval() =>
|
||||
Command("eval",
|
||||
Param("code", typeof(string), required: true),
|
||||
Param("timeout", typeof(int)));
|
||||
|
||||
[Test]
|
||||
public void TryBind_PositionalAssigningAParameterOtherThanItsOwnSlot_IsAValue()
|
||||
{
|
||||
// `eval "timeout=5;"` is legal C#. It lands on the code slot, and code is not what
|
||||
// the text before `=` names, so nothing about it suggests a misremembered flag.
|
||||
// Matching ANY declared name rejected it, while the identical structured request
|
||||
// {"code":"timeout=5;"} succeeded.
|
||||
var bound = Bind(Eval(), "timeout=5;");
|
||||
|
||||
Assert.IsTrue(JToken.DeepEquals(new JObject { ["code"] = "timeout=5;" }, bound),
|
||||
$"expected the token to bind as code, got {bound}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_PositionalAssigningItsOwnSlot_IsStillABareAssignment()
|
||||
{
|
||||
// `create_folder path=Assets/Foo` is the case the check exists for: the token lands
|
||||
// on `path` and its own text names `path`, which is a misremembered `--path`.
|
||||
var command = Command("create_folder", Param("path", typeof(string), required: true));
|
||||
|
||||
var problems = BindProblems(command, "path=Assets/Foo");
|
||||
|
||||
Assert.AreEqual(ArgProblemKind.BareAssignment, problems[0].Kind);
|
||||
Assert.AreEqual("path=Assets/Foo", problems[0].Token);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_PositionalAfterTheSeparator_IsNeverABareAssignment()
|
||||
{
|
||||
// The documented escape hatch, and what makes the residual ambiguity of a token that
|
||||
// assigns its own slot (`eval "code=1;"`) recoverable rather than fatal.
|
||||
var bound = Bind(Eval(), "--", "code=1;");
|
||||
|
||||
Assert.IsTrue(JToken.DeepEquals(new JObject { ["code"] = "code=1;" }, bound),
|
||||
$"expected the token to bind as code, got {bound}");
|
||||
}
|
||||
|
||||
// ------------------------------------------------- untyped targets
|
||||
|
||||
[Test]
|
||||
public void TryBind_BareFlagOnAJsonObjectParameter_IsAnEmptyValue()
|
||||
{
|
||||
// A JObject cannot hold `true`, so a bare `--metadata` is a missing value, not a type
|
||||
// error. The untyped exemption covered every JToken subtype, so the implied `true`
|
||||
// reached the converter and surfaced as TypeMismatch instead.
|
||||
var command = Command("tag", Param("metadata", typeof(JObject)));
|
||||
|
||||
var problems = BindProblems(command, "--metadata");
|
||||
|
||||
Assert.AreEqual(1, problems.Count, Describe(problems));
|
||||
Assert.AreEqual(ArgProblemKind.EmptyValue, problems[0].Kind);
|
||||
Assert.AreEqual("metadata", problems[0].Name);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_BareFlagOnAFullyUntypedParameter_IsStillAccepted()
|
||||
{
|
||||
// `object` and bare `JToken` CAN hold true, so the exemption still applies to them.
|
||||
var command = Command("tag", Param("anything", typeof(object)));
|
||||
|
||||
Assert.IsTrue(JToken.DeepEquals(new JObject { ["anything"] = true },
|
||||
Bind(command, "--anything")));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_TypeMismatchOnAnUntypedTarget_EchoesTheTokenTheUserTyped()
|
||||
{
|
||||
// An untyped target stores a literal-parsed JValue, so the stored value is not a
|
||||
// string and the token text was dropped - rendering "expects JObject, but got ''".
|
||||
var command = Command("tag", Param("metadata", typeof(JObject)));
|
||||
|
||||
var problems = BindProblems(command, "--metadata", "5");
|
||||
|
||||
Assert.AreEqual(1, problems.Count, Describe(problems));
|
||||
Assert.AreEqual(ArgProblemKind.TypeMismatch, problems[0].Kind);
|
||||
Assert.AreEqual("5", problems[0].Token, "the diagnostic must name what the user typed");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryBind_TypeMismatchOnAnUntypedTarget_EchoesTheTokenVerbatim()
|
||||
{
|
||||
// The literal rules turn `007` into 7, so echoing the stored value would misquote the
|
||||
// user back at themselves.
|
||||
var command = Command("tag", Param("metadata", typeof(JObject)));
|
||||
|
||||
var problems = BindProblems(command, "--metadata", "007");
|
||||
|
||||
Assert.AreEqual("007", problems[0].Token);
|
||||
}
|
||||
|
||||
// ------------------------------------------------- enums
|
||||
|
||||
private enum Severity { Info = 1, Warning = 2, Error = 4 }
|
||||
|
||||
[System.Flags]
|
||||
private enum Channels { None = 0, Info = 1, Warning = 2, Error = 4 }
|
||||
|
||||
[Test]
|
||||
public void TryBind_EnumGivenAsAFlagValue_ReachesTheSameConverter()
|
||||
{
|
||||
// The table above binds enums positionally; a --flag value takes a different path
|
||||
// through pass 1, so pin that it lands on the same coercion check.
|
||||
var command = Command("log", Param("channels", typeof(Channels)));
|
||||
|
||||
Assert.IsTrue(JToken.DeepEquals(new JObject { ["channels"] = "Info,Warning" },
|
||||
Bind(command, "--channels", "Info,Warning")));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dfbc17c1da9dcd941b413e7e8f7f6c54
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Collections.Generic;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Unit tests for CommandLineTokenizer - the POSIX-ish lexer that splits a raw
|
||||
/// <c>commandLine</c> string into argv tokens before binding.
|
||||
///
|
||||
/// These tests are the written form of the dialect contract: the dialect is the
|
||||
/// pipeline's OWN, identical on every OS, and documented in connectivity.md. Changing
|
||||
/// any expectation here is a wire-contract change, not a refactor. In particular the
|
||||
/// <c>WindowsDoubledQuoteIsNotAnEscape</c> case pins us to POSIX rules over
|
||||
/// <c>CommandLineToArgvW</c>.
|
||||
///
|
||||
/// The dialect is a pure input-to-output function, so the cases are a table rather than
|
||||
/// ~18 near-identical methods. Each case keeps its own name and, where the rule is not
|
||||
/// self-evident, the reason it is that way.
|
||||
/// </summary>
|
||||
class CommandLineTokenizerTests
|
||||
{
|
||||
private static List<string> Tokenize(string commandLine)
|
||||
{
|
||||
Assert.IsTrue(CommandLineTokenizer.TryTokenize(commandLine, out var tokens, out var error),
|
||||
$"Expected '{commandLine}' to tokenize, but failed with: {error}");
|
||||
Assert.IsNull(error, "A successful tokenize must not report an error");
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private static TestCaseData Case(string name, string commandLine, params string[] expected)
|
||||
{
|
||||
return new TestCaseData(commandLine, expected).SetName($"TryTokenize_{name}");
|
||||
}
|
||||
|
||||
private static IEnumerable<TestCaseData> TokenizeCases()
|
||||
{
|
||||
yield return Case("SimpleWordsSplitOnWhitespace",
|
||||
"eval return 2+2;", "eval", "return", "2+2;");
|
||||
|
||||
// Leading, trailing, tabs and repeated spaces all collapse.
|
||||
yield return Case("WhitespaceRunsProduceNoEmptyTokens", " a \t b ", "a", "b");
|
||||
|
||||
// A pure lexer has no opinion about needing a command name; the caller rejects an
|
||||
// empty token list so the message matches the structured path.
|
||||
yield return Case("EmptyStringYieldsNoTokens", "");
|
||||
yield return Case("WhitespaceOnlyYieldsNoTokens", " ");
|
||||
yield return Case("NullYieldsNoTokens", null);
|
||||
|
||||
// eval "return 2+2;"
|
||||
yield return Case("DoubleQuotesGroupWhitespaceIntoOneToken",
|
||||
"eval \"return 2+2;\"", "eval", "return 2+2;");
|
||||
|
||||
// eval 'obj.name = "x";' <- the reason single quotes are load-bearing
|
||||
yield return Case("SingleQuotesGroupWhitespaceAndPassDoubleQuotesThrough",
|
||||
"eval 'obj.name = \"x\";'", "eval", "obj.name = \"x\";");
|
||||
|
||||
// "a\"b" -> a"b
|
||||
yield return Case("BackslashInsideDoubleQuotesEscapesNextCharacter",
|
||||
"\"a\\\"b\"", "a\"b");
|
||||
|
||||
// 'a\b' -> a\b (single quotes have NO escapes at all)
|
||||
yield return Case("BackslashInsideSingleQuotesIsLiteral", "'a\\b'", "a\\b");
|
||||
|
||||
// a\ b -> one token "a b"
|
||||
yield return Case("BackslashOutsideQuotesEscapesNextCharacter", "a\\ b", "a b");
|
||||
|
||||
yield return Case("SingleQuoteInsideDoubleQuotesIsLiteral", "\"it's\"", "it's");
|
||||
|
||||
// a"b c"d -> ab cd
|
||||
yield return Case("AdjacentSpansConcatenateIntoOneToken", "a\"b c\"d", "ab cd");
|
||||
|
||||
// "" -> a single EMPTY token, not zero tokens. This is what lets `--message ""`
|
||||
// mean the empty string instead of the boolean true.
|
||||
yield return Case("EmptyQuotedSpanYieldsOneEmptyToken", "\"\"", string.Empty);
|
||||
|
||||
// PINS THE DIALECT. Windows CommandLineToArgvW reads "a""b" as a"b. POSIX-ish closes
|
||||
// the span and opens a new one, so it concatenates to ab.
|
||||
yield return Case("WindowsDoubledQuoteIsNotAnEscape", "\"a\"\"b\"", "ab");
|
||||
|
||||
// No expansion of any kind: no operators, no $VAR, no globbing, no ~.
|
||||
yield return Case("ShellOperatorsAreLiteralTokens",
|
||||
"a && b | c > d $HOME *.cs ~",
|
||||
"a", "&&", "b", "|", "c", ">", "d", "$HOME", "*.cs", "~");
|
||||
|
||||
yield return Case("UnityAssetPathSurvivesUnchanged",
|
||||
"reload_file Assets/Scripts/Player.cs", "reload_file", "Assets/Scripts/Player.cs");
|
||||
|
||||
// The tokenizer does not interpret flags; the binder does.
|
||||
yield return Case("FlagsAndDoubleDashArePlainTokens",
|
||||
"log_editor --message hi -- -x", "log_editor", "--message", "hi", "--", "-x");
|
||||
}
|
||||
|
||||
[TestCaseSource(nameof(TokenizeCases))]
|
||||
public void TryTokenize_ProducesTheExpectedTokens(string commandLine, string[] expected)
|
||||
{
|
||||
CollectionAssert.AreEqual(expected, Tokenize(commandLine));
|
||||
}
|
||||
|
||||
[TestCase("eval \"unbalanced", "quote", TestName = "TryTokenize_UnbalancedDoubleQuote_IsRejected")]
|
||||
[TestCase("eval 'unbalanced", "quote", TestName = "TryTokenize_UnbalancedSingleQuote_IsRejected")]
|
||||
[TestCase("abc\\", "backslash", TestName = "TryTokenize_DanglingBackslash_IsRejected")]
|
||||
public void TryTokenize_MalformedInput_IsRejectedWithAnExplanation(
|
||||
string commandLine, string expectedInMessage)
|
||||
{
|
||||
Assert.IsFalse(CommandLineTokenizer.TryTokenize(commandLine, out _, out var error),
|
||||
$"Expected '{commandLine}' to be rejected");
|
||||
Assert.IsNotEmpty(error, "A failed tokenize must explain why");
|
||||
StringAssert.Contains(expectedInMessage, error.ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 869902abb647be9469c368bab58d18db
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,389 @@
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Editor;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for command registration system using [CliCommand] and [CliArg] attributes.
|
||||
/// These test the attribute-based command definition that CLI tools will discover.
|
||||
/// </summary>
|
||||
class CommandRegistrationTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
// Ensure TypeCache discovery is available for tests
|
||||
CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
|
||||
}
|
||||
[Test]
|
||||
public void CliCommandAttribute_AppliedToMethod_CanBeRetrievedViaReflection()
|
||||
{
|
||||
// Arrange - Get the test command method (private static, resolved via reflection)
|
||||
var methodInfo = typeof(DummyCommands).GetMethod("TestLogCommand",
|
||||
BindingFlags.NonPublic | BindingFlags.Static);
|
||||
Assert.IsNotNull(methodInfo, "Test command method should exist");
|
||||
|
||||
// Act - Try to get the CliCommand attribute
|
||||
var attribute = methodInfo.GetCustomAttribute<CliCommandAttribute>();
|
||||
|
||||
// Assert - Attribute should be present and contain expected data
|
||||
Assert.IsNotNull(attribute, "Method should have CliCommand attribute");
|
||||
Assert.AreEqual("log_editor", attribute.Name, "Command name should match attribute");
|
||||
Assert.AreEqual("Log a message to Unity Editor console", attribute.Description,
|
||||
"Command description should match attribute");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CliArgAttribute_AppliedToParameter_CanBeRetrievedViaReflection()
|
||||
{
|
||||
// Arrange - Get the test command method and its parameters (private static)
|
||||
var methodInfo = typeof(DummyCommands).GetMethod("TestLogCommand",
|
||||
BindingFlags.NonPublic | BindingFlags.Static);
|
||||
var parameters = methodInfo.GetParameters();
|
||||
Assert.AreEqual(1, parameters.Length, "Test command should have one parameter");
|
||||
|
||||
// Act - Try to get the CliArg attribute from the parameter
|
||||
var parameter = parameters[0];
|
||||
var attribute = parameter.GetCustomAttribute<CliArgAttribute>();
|
||||
|
||||
// Assert - Parameter attribute should be present and contain expected data
|
||||
Assert.IsNotNull(attribute, "Parameter should have CliArg attribute");
|
||||
Assert.AreEqual("message", attribute.Name, "Parameter name should match attribute");
|
||||
Assert.AreEqual("The message to log", attribute.Description,
|
||||
"Parameter description should match attribute");
|
||||
Assert.IsTrue(attribute.Required, "Parameter should be marked as required");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CommandRegistry_DiscoverCommands_FindsRegisteredCommands()
|
||||
{
|
||||
// Arrange & Act - Discover all commands via CommandRegistry
|
||||
var commands = CommandRegistry.DiscoverCommands();
|
||||
|
||||
// Assert - Should find our test command
|
||||
Assert.IsNotNull(commands, "Command discovery should return a collection");
|
||||
Assert.Greater(commands.Count(), 0, "Should discover at least one command");
|
||||
|
||||
// Find our specific test command
|
||||
var testCommand = commands.FirstOrDefault(cmd => cmd.Name == "log_editor");
|
||||
Assert.IsNotNull(testCommand, "Should discover the log_editor test command");
|
||||
Assert.AreEqual("Log a message to Unity Editor console", testCommand.Description);
|
||||
Assert.IsTrue(testCommand.MainThreadRequired, "Test command should require main thread");
|
||||
|
||||
// Verify parameter information
|
||||
Assert.AreEqual(1, testCommand.Parameters.Count, "Command should have one parameter");
|
||||
var messageParam = testCommand.Parameters.First();
|
||||
Assert.AreEqual("message", messageParam.Name);
|
||||
Assert.AreEqual("The message to log", messageParam.Description);
|
||||
Assert.IsTrue(messageParam.Required);
|
||||
Assert.AreEqual(typeof(string), messageParam.ParameterType);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CommandRegistry_DiscoverCommands_PopulatesRuntimeOnlyFlag()
|
||||
{
|
||||
// Arrange & Act
|
||||
var commands = CommandRegistry.DiscoverCommands().ToList();
|
||||
|
||||
// Assert - runtime commands are tagged RuntimeOnly, editor commands are not
|
||||
var editorStatus = commands.First(c => c.Name == "editor_status");
|
||||
var runtimeStatus = commands.First(c => c.Name == "runtime_status");
|
||||
|
||||
Assert.IsTrue(runtimeStatus.RuntimeOnly, "eval should be marked RuntimeOnly");
|
||||
Assert.IsFalse(editorStatus.RuntimeOnly, "editor_status should not be RuntimeOnly");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CommandRegistry_DiscoverCommands_PopulatesTagsFromAttribute()
|
||||
{
|
||||
// Arrange & Act
|
||||
var commands = CommandRegistry.DiscoverCommands();
|
||||
|
||||
// Assert - path-style tags flow from [CliCommand(Tags = ...)] into CommandInfo
|
||||
var tagged = commands.FirstOrDefault(cmd => cmd.Name == "test_tagged");
|
||||
Assert.IsNotNull(tagged, "Should discover the test_tagged test command");
|
||||
CollectionAssert.AreEqual(new[] { "test/registration", "test" }, tagged.Tags,
|
||||
"Tags should flow from the [CliCommand] attribute into CommandInfo");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CommandRegistry_DiscoverCommands_UntaggedCommandHasEmptyTags()
|
||||
{
|
||||
// Arrange & Act
|
||||
var commands = CommandRegistry.DiscoverCommands();
|
||||
|
||||
// Assert - commands without Tags get an empty (never null) list
|
||||
var untagged = commands.First(cmd => cmd.Name == "log_editor");
|
||||
Assert.IsNotNull(untagged.Tags, "Tags should never be null");
|
||||
Assert.AreEqual(0, untagged.Tags.Count, "Untagged command should have no tags");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CommandRegistry_DiscoverCommands_AllShippedCommandsCarryTags()
|
||||
{
|
||||
// Arrange & Act
|
||||
var commands = CommandRegistry.DiscoverCommands();
|
||||
|
||||
// Assert - every command shipped in the package assemblies is browsable by tag.
|
||||
// Test-fixture commands (Unity.Pipeline.Tests.*) are exempt: several tests
|
||||
// depend on deliberately untagged fixtures (e.g. log_editor).
|
||||
var shippedAssemblies = new[] { "Unity.Pipeline", "Unity.Pipeline.Editor" };
|
||||
var untagged = commands
|
||||
.Where(cmd => shippedAssemblies.Contains(cmd.Package))
|
||||
.Where(cmd => cmd.Tags.Count == 0)
|
||||
.Select(cmd => cmd.Name)
|
||||
.OrderBy(name => name)
|
||||
.ToList();
|
||||
Assert.IsEmpty(untagged,
|
||||
$"Shipped commands must declare at least one tag: {string.Join(", ", untagged)}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CommandRegistry_DiscoverCommands_TagsAreWellFormedPaths()
|
||||
{
|
||||
// Arrange & Act
|
||||
var commands = CommandRegistry.DiscoverCommands();
|
||||
|
||||
// Assert - tags are lowercase '/'-separated paths so tag-subtree filtering
|
||||
// and group_by=tag nesting behave predictably
|
||||
var pattern = new System.Text.RegularExpressions.Regex("^[a-z0-9_]+(/[a-z0-9_]+)*$");
|
||||
var malformed = commands
|
||||
.SelectMany(cmd => cmd.Tags
|
||||
.Where(tag => !pattern.IsMatch(tag))
|
||||
.Select(tag => $"{cmd.Name}: '{tag}'"))
|
||||
.ToList();
|
||||
Assert.IsEmpty(malformed,
|
||||
$"Tags must be lowercase path-style segments: {string.Join(", ", malformed)}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CommandRegistry_DiscoverCommands_DerivesPackageFromDeclaringAssembly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var commands = CommandRegistry.DiscoverCommands().ToList();
|
||||
|
||||
// Assert - each command carries the assembly it originates from
|
||||
var testCommand = commands.First(cmd => cmd.Name == "log_editor");
|
||||
Assert.AreEqual("Unity.Pipeline.Tests.Editor", testCommand.Package,
|
||||
"Test-assembly command should report the test assembly as its package");
|
||||
|
||||
var editorCommand = commands.First(cmd => cmd.Name == "editor_status");
|
||||
Assert.AreEqual("Unity.Pipeline.Editor", editorCommand.Package,
|
||||
"Editor command should report the editor assembly as its package");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void JsonSchemaGeneration_FromCommandInfo_CreatesValidSchema()
|
||||
{
|
||||
// Arrange - Get our test command
|
||||
var commands = CommandRegistry.DiscoverCommands();
|
||||
var testCommand = commands.First(cmd => cmd.Name == "log_editor");
|
||||
|
||||
// Act - Generate JSON schema for the command
|
||||
var schema = JsonSchemaGenerator.GenerateCommandSchema(testCommand);
|
||||
|
||||
// Assert - Schema should be valid JSON Schema format
|
||||
Assert.IsNotNull(schema, "Schema should not be null");
|
||||
|
||||
// Parse as JSON to verify structure
|
||||
var schemaJson = JObject.Parse(schema);
|
||||
|
||||
// Verify root schema properties
|
||||
Assert.AreEqual("object", schemaJson["type"]?.ToString());
|
||||
Assert.IsNotNull(schemaJson["properties"], "Schema should have properties section");
|
||||
Assert.IsNotNull(schemaJson["required"], "Schema should have required array");
|
||||
|
||||
// Verify command metadata
|
||||
Assert.AreEqual("log_editor", schemaJson["title"]?.ToString());
|
||||
Assert.AreEqual("Log a message to Unity Editor console", schemaJson["description"]?.ToString());
|
||||
|
||||
// Verify parameter schema
|
||||
var properties = schemaJson["properties"] as JObject;
|
||||
Assert.IsNotNull(properties["message"], "Should have message parameter");
|
||||
|
||||
var messageProperty = properties["message"] as JObject;
|
||||
Assert.AreEqual("string", messageProperty["type"]?.ToString());
|
||||
Assert.AreEqual("The message to log", messageProperty["description"]?.ToString());
|
||||
|
||||
// Verify required array
|
||||
var required = schemaJson["required"] as JArray;
|
||||
Assert.Contains("message", required.ToObject<string[]>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void JsonSchemaGeneration_MultipleParameterTypes_MapsTypesCorrectly()
|
||||
{
|
||||
// Arrange - Get test command with multiple parameter types
|
||||
var commands = CommandRegistry.DiscoverCommands();
|
||||
var testCommand = commands.First(cmd => cmd.Name == "test_types");
|
||||
|
||||
// Act - Generate schema
|
||||
var schema = JsonSchemaGenerator.GenerateCommandSchema(testCommand);
|
||||
var schemaJson = JObject.Parse(schema);
|
||||
|
||||
// Assert - Verify different parameter types are mapped correctly
|
||||
var properties = schemaJson["properties"] as JObject;
|
||||
|
||||
// Integer parameter
|
||||
var countProperty = properties["count"] as JObject;
|
||||
Assert.AreEqual("integer", countProperty["type"]?.ToString());
|
||||
Assert.AreEqual(1, countProperty["default"]?.ToObject<int>());
|
||||
|
||||
// Boolean parameter
|
||||
var enabledProperty = properties["enabled"] as JObject;
|
||||
Assert.AreEqual("boolean", enabledProperty["type"]?.ToString());
|
||||
Assert.AreEqual(false, enabledProperty["default"]?.ToObject<bool>());
|
||||
|
||||
// Float parameter
|
||||
var factorProperty = properties["factor"] as JObject;
|
||||
Assert.AreEqual("number", factorProperty["type"]?.ToString());
|
||||
Assert.AreEqual(1.0f, factorProperty["default"]?.ToObject<float>());
|
||||
|
||||
// Verify no required parameters (all have defaults)
|
||||
var required = schemaJson["required"] as JArray;
|
||||
Assert.AreEqual(0, required.Count, "All parameters should be optional with default values");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void JsonSchemaGeneration_StructuredObjectParameter_EmitsNestedObjectSchema()
|
||||
{
|
||||
// Arrange - command whose single parameter is an IStructuredCommandInput DTO
|
||||
var commands = CommandRegistry.DiscoverCommands();
|
||||
var testCommand = commands.First(cmd => cmd.Name == "test_structured");
|
||||
|
||||
// Act
|
||||
var schemaJson = JObject.Parse(JsonSchemaGenerator.GenerateCommandSchema(testCommand));
|
||||
|
||||
// Assert - the payload param is a real nested object schema, not a "string" fallback
|
||||
var payload = schemaJson["properties"]?["payload"] as JObject;
|
||||
Assert.IsNotNull(payload, "payload parameter should be present");
|
||||
Assert.AreEqual("object", payload["type"]?.ToString(), "structured param should map to object");
|
||||
Assert.AreEqual(false, payload["additionalProperties"]?.ToObject<bool>());
|
||||
|
||||
var props = payload["properties"] as JObject;
|
||||
Assert.IsNotNull(props, "nested object should expose its own properties");
|
||||
|
||||
// Scalar member with description from [CliArg]
|
||||
Assert.AreEqual("string", props["name"]?["type"]?.ToString());
|
||||
Assert.AreEqual("Display name", props["name"]?["description"]?.ToString());
|
||||
Assert.AreEqual("integer", props["count"]?["type"]?.ToString());
|
||||
|
||||
// Array member emits items schema
|
||||
Assert.AreEqual("array", props["tags"]?["type"]?.ToString());
|
||||
Assert.AreEqual("string", props["tags"]?["items"]?["type"]?.ToString());
|
||||
|
||||
// Nested structured member recurses
|
||||
var nested = props["nested"] as JObject;
|
||||
Assert.IsNotNull(nested, "nested structured member should be present");
|
||||
Assert.AreEqual("object", nested["type"]?.ToString());
|
||||
Assert.AreEqual("boolean", nested["properties"]?["enabled"]?["type"]?.ToString());
|
||||
|
||||
// Required propagates from [CliArg(Required = true)] on a member
|
||||
var required = payload["required"]?.ToObject<string[]>();
|
||||
Assert.IsNotNull(required, "structured object should have a required array");
|
||||
CollectionAssert.Contains(required, "name");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EditorStatusCommand_IsDiscovered_ByCommandRegistry()
|
||||
{
|
||||
// Arrange & Act - Discover all commands
|
||||
var commands = CommandRegistry.DiscoverCommands();
|
||||
|
||||
// Assert - Should find the editor_status command
|
||||
var editorStatusCommand = commands.FirstOrDefault(cmd => cmd.Name == "editor_status");
|
||||
Assert.IsNotNull(editorStatusCommand, "Should discover the editor_status command");
|
||||
Assert.AreEqual("Get detailed Unity Editor status and state information", editorStatusCommand.Description);
|
||||
Assert.IsTrue(editorStatusCommand.MainThreadRequired, "editor_status should require main thread");
|
||||
|
||||
// Verify it has no parameters (editor status doesn't need input)
|
||||
Assert.AreEqual(0, editorStatusCommand.Parameters.Count, "editor_status should have no parameters");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EditorStatusCommand_DirectExecution_WorksCorrectly()
|
||||
{
|
||||
// Arrange & Act - Execute editor_status command directly (bypass all HTTP and threading)
|
||||
try
|
||||
{
|
||||
var result = Unity.Pipeline.Editor.Commands.EditorStatusCommand.GetEditorStatus();
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(result, "editor_status command should return a result");
|
||||
Assert.IsInstanceOf<Unity.Pipeline.Models.StatusResponse>(result, "Should return StatusResponse type");
|
||||
|
||||
var status = result as Unity.Pipeline.Models.StatusResponse;
|
||||
Assert.IsNotNull(status.Status, "Status should have Status field");
|
||||
Assert.IsNotNull(status.UnityVersion, "Status should have UnityVersion field");
|
||||
}
|
||||
catch (System.Exception)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test commands for verifying command registration attributes.
|
||||
/// </summary>
|
||||
static class DummyCommands
|
||||
{
|
||||
// Private on purpose: verifies the registry can discover and execute
|
||||
// non-public static methods (see CommandRegistry.CreateCommandInfo).
|
||||
[CliCommand("log_editor", "Log a message to Unity Editor console")]
|
||||
private static void TestLogCommand([CliArg("message", "The message to log", Required = true)] string message)
|
||||
{
|
||||
UnityEngine.Debug.Log(message);
|
||||
}
|
||||
|
||||
[CliCommand("test_types", "Test command with various parameter types")]
|
||||
public static void TestTypesCommand(
|
||||
[CliArg("count", "Number of items")] int count = 1,
|
||||
[CliArg("enabled", "Whether feature is enabled")] bool enabled = false,
|
||||
[CliArg("factor", "Multiplier factor")] float factor = 1.0f)
|
||||
{
|
||||
// Test command with various types
|
||||
}
|
||||
|
||||
[CliCommand("test_tagged", "Test command with hierarchical tags",
|
||||
Tags = new[] { "test/registration", "test" })]
|
||||
public static void TestTaggedCommand()
|
||||
{
|
||||
// Carries path-style tags to verify they flow into CommandInfo
|
||||
}
|
||||
|
||||
[CliCommand("test_structured", "Test command with a structured object parameter")]
|
||||
public static void TestStructuredCommand(
|
||||
[CliArg("payload", "Structured payload")] SampleStructuredInput payload = null)
|
||||
{
|
||||
// Test command with a nested-object parameter
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Structured-input DTO used to verify nested object schema generation.</summary>
|
||||
class SampleStructuredInput : IStructuredCommandInput
|
||||
{
|
||||
[CliArg("name", "Display name", Required = true)]
|
||||
public string Name { get; set; }
|
||||
|
||||
[CliArg("count", "How many")]
|
||||
public int Count { get; set; }
|
||||
|
||||
[CliArg("tags", "Associated tags")]
|
||||
public string[] Tags { get; set; }
|
||||
|
||||
[CliArg("nested", "Nested child object")]
|
||||
public SampleNestedInput Nested { get; set; }
|
||||
}
|
||||
|
||||
class SampleNestedInput : IStructuredCommandInput
|
||||
{
|
||||
[CliArg("enabled", "Toggle")]
|
||||
public bool Enabled { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9a2753687e13238458ed342206a00d72
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ddbcf4b2309b5fe4eb468f2437e44809
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Console;
|
||||
using Unity.Pipeline.Runtime.Commands;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor.Console
|
||||
{
|
||||
class ConsoleCaptureResubscribeTests
|
||||
{
|
||||
[UnityTearDown]
|
||||
public IEnumerator TearDown()
|
||||
{
|
||||
if (EditorApplication.isPlaying)
|
||||
yield return new ExitPlayMode();
|
||||
|
||||
RearmConsoleLogCapture();
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator ConsoleCapture_SurvivesPlayModeExit()
|
||||
{
|
||||
yield return new EnterPlayMode();
|
||||
yield return new ExitPlayMode();
|
||||
|
||||
var marker = NewMarker("survives_exit");
|
||||
EmitError(marker);
|
||||
|
||||
var consoleResponse = ConsoleCommand.GetConsole(level: "error");
|
||||
Assert.IsTrue(consoleResponse.Entries.Any(e => e.Message != null && e.Message.Contains(marker)),
|
||||
"console must still capture entries logged after exiting play mode");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CaptureSurvivesLostSubscription_WhenRearmed()
|
||||
{
|
||||
StripConsoleLogCaptureSubscription();
|
||||
|
||||
var deadMarker = NewMarker("dead");
|
||||
EmitError(deadMarker);
|
||||
Assert.AreEqual(0, CountCaptures(deadMarker),
|
||||
"Stripping the subscription should stop capture (sanity check on the strip step)");
|
||||
|
||||
RearmConsoleLogCapture();
|
||||
|
||||
var liveMarker = NewMarker("live");
|
||||
EmitError(liveMarker);
|
||||
Assert.AreEqual(1, CountCaptures(liveMarker),
|
||||
"Re-arming the subscription must restore capture");
|
||||
|
||||
RearmConsoleLogCapture();
|
||||
|
||||
var idempotentMarker = NewMarker("idempotent");
|
||||
EmitError(idempotentMarker);
|
||||
Assert.AreEqual(1, CountCaptures(idempotentMarker),
|
||||
"Re-arming an already-live subscription must not double-record a single log");
|
||||
}
|
||||
|
||||
private static string NewMarker(string tag) => $"consolecapture_{tag}_{Guid.NewGuid():N}";
|
||||
|
||||
private static void EmitError(string marker)
|
||||
{
|
||||
LogAssert.Expect(LogType.Error, new Regex(".*" + Regex.Escape(marker) + ".*"));
|
||||
Debug.LogError(marker);
|
||||
}
|
||||
|
||||
private static void StripConsoleLogCaptureSubscription()
|
||||
{
|
||||
var method = typeof(ConsoleLogCapture).GetMethod("OnLogMessage", BindingFlags.NonPublic | BindingFlags.Static);
|
||||
Assert.IsNotNull(method, "ConsoleLogCapture.OnLogMessage must exist for this test to strip its subscription");
|
||||
var handler = (Application.LogCallback)Delegate.CreateDelegate(typeof(Application.LogCallback), method);
|
||||
Application.logMessageReceivedThreaded -= handler;
|
||||
}
|
||||
|
||||
private static void RearmConsoleLogCapture() => ConsoleLogCapture.EnsureCapturing();
|
||||
|
||||
private static int CountCaptures(string marker) =>
|
||||
ConsoleLogCapture.Buffer.Query(-1, 0, ConsoleLogBuffer.SeverityLog)
|
||||
.Entries.Count(e => e.Message != null && e.Message.Contains(marker));
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d89fccd0e0213ab4bbde1251233cd766
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Console;
|
||||
using Unity.Pipeline.Editor.Console;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor.Console
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the Editor console ground-truth sampler: what it publishes, that seeding leaves the
|
||||
/// user's console filtering exactly as it found it, and that it re-arms capture when the console
|
||||
/// moves and the buffer's cursor does not.
|
||||
/// </summary>
|
||||
class EditorConsoleGroundTruthTests
|
||||
{
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
// A failed watchdog test would otherwise leave capture dead for the rest of the run.
|
||||
ConsoleLogCapture.EnsureCapturing();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Sample_PublishesConsoleCountsAndCompilationFlag()
|
||||
{
|
||||
var marker = NewMarker("groundtruth");
|
||||
EmitWarning(marker);
|
||||
|
||||
EditorConsoleGroundTruth.SampleForTests();
|
||||
|
||||
var truth = ConsoleLogCapture.GroundTruth;
|
||||
Assert.IsNotNull(truth, "Sampling must publish a snapshot");
|
||||
Assert.Greater(truth.ConsoleWarnings, 0, "The warning just logged is in the Editor console");
|
||||
Assert.Less((DateTime.UtcNow - truth.SampledUtc).TotalSeconds, 30, "The snapshot should be the one just taken");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Seed_RestoresConsoleFlagsAndFilterText()
|
||||
{
|
||||
if (!EditorConsoleEntries.Available)
|
||||
Assert.Ignore("UnityEditor.LogEntries is not reachable on this Editor version");
|
||||
|
||||
var originalFlags = GetConsoleFlags();
|
||||
var originalFilter = GetFilteringText();
|
||||
try
|
||||
{
|
||||
// Restrict the console the way a user might: hide errors and set a search filter.
|
||||
// Seeding has to force both open to see every entry, then put them back.
|
||||
SetConsoleFlags(originalFlags & ~LogLevelError);
|
||||
SetFilteringText("pipeline_seed_filter_probe");
|
||||
|
||||
var restrictedFlags = GetConsoleFlags();
|
||||
var restrictedFilter = GetFilteringText();
|
||||
|
||||
EditorConsoleGroundTruth.ResetForTests();
|
||||
EditorConsoleGroundTruth.SampleForTests();
|
||||
|
||||
Assert.AreEqual(restrictedFlags, GetConsoleFlags(), "Seeding must leave the console's level toggles as it found them");
|
||||
Assert.AreEqual(restrictedFilter, GetFilteringText(), "Seeding must leave the console's search text as it found it");
|
||||
}
|
||||
finally
|
||||
{
|
||||
SetFilteringText(originalFilter);
|
||||
SetConsoleFlags(originalFlags);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Seed_DoesNotDuplicateEntriesAlreadyCaptured()
|
||||
{
|
||||
if (!EditorConsoleEntries.Available)
|
||||
Assert.Ignore("UnityEditor.LogEntries is not reachable on this Editor version");
|
||||
|
||||
var marker = NewMarker("dedupe");
|
||||
EmitError(marker);
|
||||
Assert.AreEqual(1, CountCaptures(marker), "The live callback captured it once");
|
||||
|
||||
EditorConsoleGroundTruth.ResetForTests();
|
||||
EditorConsoleGroundTruth.SampleForTests();
|
||||
|
||||
Assert.AreEqual(1, CountCaptures(marker), "Seeding must not re-add an entry the buffer already holds");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Seed_RecoversEntriesLoggedWhileCaptureWasDead()
|
||||
{
|
||||
if (!EditorConsoleEntries.Available)
|
||||
Assert.Ignore("UnityEditor.LogEntries is not reachable on this Editor version");
|
||||
|
||||
// The repair path, and the shape of the reported symptom: the Editor console holds an
|
||||
// error the buffer never received, because capture was not listening when it was logged.
|
||||
// Editor compile errors are the real case — they are sticky, so they stand in the console
|
||||
// long after the callback missed them.
|
||||
StripSubscription();
|
||||
|
||||
var marker = NewMarker("missed");
|
||||
EmitError(marker);
|
||||
Assert.AreEqual(0, CountCaptures(marker),
|
||||
"Precondition: the dead subscription must have missed it");
|
||||
|
||||
ConsoleLogCapture.EnsureCapturing();
|
||||
EditorConsoleGroundTruth.ResetForTests();
|
||||
EditorConsoleGroundTruth.SampleForTests();
|
||||
|
||||
Assert.AreEqual(1, CountCaptures(marker),
|
||||
"Seeding must recover the entry the console holds and the buffer missed");
|
||||
|
||||
var seeded = ConsoleLogCapture.Buffer.Query(-1, 0, ConsoleLogBuffer.SeverityLog)
|
||||
.Entries.First(e => e.Message != null && e.Message.Contains(marker));
|
||||
Assert.IsTrue(seeded.Seeded,
|
||||
"A recovered entry must be marked, since its timestamp is the read time");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Seed_RecoversARepeatedEntryTheBufferMissed()
|
||||
{
|
||||
if (!EditorConsoleEntries.Available)
|
||||
Assert.Ignore("UnityEditor.LogEntries is not reachable on this Editor version");
|
||||
|
||||
// Console messages repeat by design, so matching on presence alone is not enough: the
|
||||
// copy the buffer already holds would cancel out the identical copy it missed, and that
|
||||
// one would never be recovered.
|
||||
var marker = NewMarker("repeated");
|
||||
EmitError(marker);
|
||||
Assert.AreEqual(1, CountCaptures(marker), "The live callback captured the first one");
|
||||
|
||||
StripSubscription();
|
||||
EmitError(marker);
|
||||
Assert.AreEqual(1, CountCaptures(marker),
|
||||
"Precondition: the second, identical entry was missed while capture was dead");
|
||||
|
||||
ConsoleLogCapture.EnsureCapturing();
|
||||
EditorConsoleGroundTruth.ResetForTests();
|
||||
EditorConsoleGroundTruth.SampleForTests();
|
||||
|
||||
Assert.AreEqual(2, CountCaptures(marker),
|
||||
"Both occurrences must be held after seeding, not just one");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Seed_RecoversEveryOccurrenceOfAPreCaptureRepeat()
|
||||
{
|
||||
if (!EditorConsoleEntries.Available)
|
||||
Assert.Ignore("UnityEditor.LogEntries is not reachable on this Editor version");
|
||||
|
||||
// A fresh seed with nothing held: all three identical rows have to come across.
|
||||
StripSubscription();
|
||||
var marker = NewMarker("triple");
|
||||
EmitError(marker);
|
||||
EmitError(marker);
|
||||
EmitError(marker);
|
||||
Assert.AreEqual(0, CountCaptures(marker), "Precondition: capture was dead for all three");
|
||||
|
||||
ConsoleLogCapture.EnsureCapturing();
|
||||
EditorConsoleGroundTruth.ResetForTests();
|
||||
EditorConsoleGroundTruth.SampleForTests();
|
||||
|
||||
Assert.AreEqual(3, CountCaptures(marker), "Every occurrence must be recovered");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Watchdog_ReArmsCaptureWhenConsoleMovedAndBufferDidNot()
|
||||
{
|
||||
StripSubscription();
|
||||
|
||||
// Baseline, then a log the dead subscription cannot deliver: the console's count moves
|
||||
// while the buffer's cursor stands still, which is the only symptom available.
|
||||
EditorConsoleGroundTruth.SampleForTests();
|
||||
EmitError(NewMarker("gap"));
|
||||
EditorConsoleGroundTruth.SampleForTests();
|
||||
EditorConsoleGroundTruth.SampleForTests();
|
||||
|
||||
var marker = NewMarker("recovered");
|
||||
EmitError(marker);
|
||||
Assert.AreEqual(1, CountCaptures(marker), "The watchdog must have re-armed capture");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EditorConsoleEntries_ReadsRecentEntries()
|
||||
{
|
||||
if (!EditorConsoleEntries.Available)
|
||||
Assert.Ignore("UnityEditor.LogEntries is not reachable on this Editor version");
|
||||
|
||||
var marker = NewMarker("read");
|
||||
EmitError(marker);
|
||||
|
||||
var entries = new List<EditorConsoleEntries.Entry>();
|
||||
Assert.IsTrue(EditorConsoleEntries.TryReadRecent(200, entries));
|
||||
Assert.IsTrue(entries.Any(e => e.Message != null && e.Message.Contains(marker)),
|
||||
"The entry just logged must be readable from the Editor console store");
|
||||
}
|
||||
|
||||
// ConsoleMode.kLogLevelError, as mirrored by the internal ConsoleWindow.ConsoleFlags.
|
||||
const int LogLevelError = 1 << 9;
|
||||
|
||||
static readonly Type k_LogEntries = Type.GetType("UnityEditor.LogEntries,UnityEditor");
|
||||
|
||||
static int GetConsoleFlags() => (int)Property("consoleFlags").GetGetMethod(true).Invoke(null, null);
|
||||
|
||||
static void SetConsoleFlags(int flags) => Property("consoleFlags").GetSetMethod(true).Invoke(null, new object[] { flags });
|
||||
|
||||
static string GetFilteringText() => (string)Method("GetFilteringText").Invoke(null, null) ?? string.Empty;
|
||||
|
||||
static void SetFilteringText(string text) => Method("SetFilteringText").Invoke(null, new object[] { text });
|
||||
|
||||
static PropertyInfo Property(string name) =>
|
||||
k_LogEntries.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
|
||||
|
||||
static MethodInfo Method(string name) =>
|
||||
k_LogEntries.GetMethod(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
|
||||
|
||||
static string NewMarker(string tag) => $"groundtruth_{tag}_{Guid.NewGuid():N}";
|
||||
|
||||
static void EmitError(string marker)
|
||||
{
|
||||
LogAssert.Expect(LogType.Error, new Regex(".*" + Regex.Escape(marker) + ".*"));
|
||||
Debug.LogError(marker);
|
||||
}
|
||||
|
||||
static void EmitWarning(string marker)
|
||||
{
|
||||
LogAssert.Expect(LogType.Warning, new Regex(".*" + Regex.Escape(marker) + ".*"));
|
||||
Debug.LogWarning(marker);
|
||||
}
|
||||
|
||||
static void StripSubscription()
|
||||
{
|
||||
var method = typeof(ConsoleLogCapture).GetMethod("OnLogMessage", BindingFlags.NonPublic | BindingFlags.Static);
|
||||
Assert.IsNotNull(method, "ConsoleLogCapture.OnLogMessage must exist for this test to strip its subscription");
|
||||
var handler = (Application.LogCallback)Delegate.CreateDelegate(typeof(Application.LogCallback), method);
|
||||
Application.logMessageReceivedThreaded -= handler;
|
||||
}
|
||||
|
||||
static int CountCaptures(string marker) =>
|
||||
ConsoleLogCapture.Buffer.Query(-1, 0, ConsoleLogBuffer.SeverityLog)
|
||||
.Entries.Count(e => e.Message != null && e.Message.Contains(marker));
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1b4a37e6cb5686043949dcba6e8f64ab
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,193 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Console;
|
||||
using Unity.Pipeline.Editor;
|
||||
using Unity.Pipeline.Runtime.Commands;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the console command (<see cref="ConsoleCommand"/>).
|
||||
///
|
||||
/// Entries are injected straight into the shared buffer via <see cref="ConsoleLogBuffer.Add"/>
|
||||
/// (rather than real Debug.Log calls) so the tests are deterministic and don't trip Unity's
|
||||
/// LogAssert on error-level logs. Each test tags its entries with a unique marker and filters on
|
||||
/// it, so unrelated editor logs captured concurrently don't affect assertions.
|
||||
/// </summary>
|
||||
class ConsoleCommandTests
|
||||
{
|
||||
string m_Marker;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
ConsoleLogCapture.Buffer.Clear();
|
||||
m_Marker = $"[ectest-{Guid.NewGuid():N}] ";
|
||||
}
|
||||
|
||||
string[] MineInOrder(Unity.Pipeline.Models.ConsoleLogResponse response)
|
||||
{
|
||||
return response.Entries
|
||||
.Where(e => e.Message != null && e.Message.StartsWith(m_Marker))
|
||||
.Select(e => e.Message.Substring(m_Marker.Length))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
void Add(LogType type, string message) =>
|
||||
ConsoleLogCapture.Buffer.Add(type, m_Marker + message, null, DateTime.UtcNow);
|
||||
|
||||
[Test]
|
||||
public void GetConsole_Default_ReturnsRecentEntriesInOrder()
|
||||
{
|
||||
Add(LogType.Log, "one");
|
||||
Add(LogType.Log, "two");
|
||||
Add(LogType.Log, "three");
|
||||
|
||||
var response = ConsoleCommand.GetConsole();
|
||||
|
||||
CollectionAssert.AreEqual(new[] { "one", "two", "three" }, MineInOrder(response));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetConsole_LevelError_ReturnsOnlyErrorSeverity()
|
||||
{
|
||||
Add(LogType.Log, "plain");
|
||||
Add(LogType.Warning, "careful");
|
||||
Add(LogType.Error, "broken");
|
||||
Add(LogType.Exception, "threw");
|
||||
|
||||
var response = ConsoleCommand.GetConsole(level: "error");
|
||||
|
||||
CollectionAssert.AreEqual(new[] { "broken", "threw" }, MineInOrder(response));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetConsole_LevelWarn_IncludesWarningsAndErrors()
|
||||
{
|
||||
Add(LogType.Log, "plain");
|
||||
Add(LogType.Warning, "careful");
|
||||
Add(LogType.Error, "broken");
|
||||
|
||||
var response = ConsoleCommand.GetConsole(level: "warn");
|
||||
|
||||
CollectionAssert.AreEqual(new[] { "careful", "broken" }, MineInOrder(response));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetConsole_Since_ReturnsOnlyNewerEntries()
|
||||
{
|
||||
Add(LogType.Log, "before");
|
||||
long cursor = ConsoleCommand.GetConsole().Cursor;
|
||||
Add(LogType.Log, "after");
|
||||
|
||||
var response = ConsoleCommand.GetConsole(since: cursor);
|
||||
|
||||
CollectionAssert.AreEqual(new[] { "after" }, MineInOrder(response));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetConsole_TailZero_IsCoercedToDefault()
|
||||
{
|
||||
Add(LogType.Log, "a");
|
||||
Add(LogType.Log, "b");
|
||||
|
||||
// tail <= 0 must not mean "unlimited"; it falls back to the default and still returns ours.
|
||||
var response = ConsoleCommand.GetConsole(tail: 0);
|
||||
|
||||
Assert.LessOrEqual(response.Returned, ConsoleCommand.DefaultTail);
|
||||
CollectionAssert.AreEqual(new[] { "a", "b" }, MineInOrder(response));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Console_IsDiscoverableWithExpectedArgs()
|
||||
{
|
||||
CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
|
||||
var command = CommandRegistry.DiscoverCommands().FirstOrDefault(c => c.Name == "console");
|
||||
|
||||
Assert.IsNotNull(command, "console should be discoverable");
|
||||
Assert.IsFalse(command.MainThreadRequired, "console reads the buffer only; no main thread needed");
|
||||
|
||||
var paramNames = command.Parameters.Select(p => p.Name).ToList();
|
||||
CollectionAssert.Contains(paramNames, "tail");
|
||||
CollectionAssert.Contains(paramNames, "level");
|
||||
CollectionAssert.Contains(paramNames, "since");
|
||||
CollectionAssert.Contains(paramNames, "since_session");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetConsole_ReportsSessionAndCounts()
|
||||
{
|
||||
Add(LogType.Log, "plain");
|
||||
Add(LogType.Error, "broken");
|
||||
|
||||
var response = ConsoleCommand.GetConsole();
|
||||
|
||||
Assert.IsNotEmpty(response.Session, "A follow client needs the session to know its cursor is still valid");
|
||||
Assert.AreEqual(ConsoleLogCapture.Buffer.Session, response.Session);
|
||||
Assert.GreaterOrEqual(response.Counts.Error, 1);
|
||||
Assert.GreaterOrEqual(response.Counts.Log, 1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetConsole_ForeignSession_ReturnsTailAndReset()
|
||||
{
|
||||
Add(LogType.Log, "before");
|
||||
var cursor = ConsoleCommand.GetConsole().Cursor;
|
||||
Add(LogType.Log, "after");
|
||||
|
||||
// The reported symptom: a cursor from an earlier editor session used to filter out every
|
||||
// entry and read as "nothing new".
|
||||
var response = ConsoleCommand.GetConsole(since: cursor, since_session: "some-earlier-session");
|
||||
|
||||
Assert.IsTrue(response.Reset);
|
||||
CollectionAssert.AreEqual(new[] { "before", "after" }, MineInOrder(response));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ConsoleStatus_IsDiscoverableAndReturnsNoEntries()
|
||||
{
|
||||
CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
|
||||
var command = CommandRegistry.DiscoverCommands().FirstOrDefault(c => c.Name == "console_status");
|
||||
|
||||
Assert.IsNotNull(command, "console_status should be discoverable");
|
||||
Assert.IsFalse(command.MainThreadRequired, "console_status must stay pollable while the editor is busy");
|
||||
|
||||
Add(LogType.Error, "broken");
|
||||
var response = ConsoleCommand.GetConsoleStatus();
|
||||
|
||||
Assert.IsEmpty(response.Entries, "console_status reports counters only");
|
||||
Assert.GreaterOrEqual(response.Counts.Error, 1);
|
||||
Assert.AreEqual(ConsoleLogCapture.Buffer.LastSeq, response.Cursor);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClearConsole_RemovesSeededMarkerButKeepsCursor()
|
||||
{
|
||||
Add(LogType.Log, "doomed");
|
||||
var cursorBefore = ConsoleLogCapture.Buffer.LastSeq;
|
||||
|
||||
ConsoleCommand.ClearConsole();
|
||||
|
||||
CollectionAssert.IsEmpty(MineInOrder(ConsoleCommand.GetConsole()),
|
||||
"Cleared entries must not come back");
|
||||
Assert.AreEqual(cursorBefore, ConsoleLogCapture.Buffer.LastSeq,
|
||||
"The cursor must survive a clear, so a follow client sees a gap rather than silently skipping it");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClearConsole_ViaClient_Succeeds()
|
||||
{
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("clear_console");
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, $"clear_console should succeed: {response.Error}");
|
||||
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
|
||||
Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 54c331b153b587c60e89bfbde9bdca1d
|
||||
@@ -0,0 +1,572 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Console;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ConsoleLogBuffer"/> — the ring buffer behind the console command.
|
||||
/// Exercised directly (no Unity log capture) for determinism.
|
||||
/// </summary>
|
||||
class ConsoleLogBufferTests
|
||||
{
|
||||
static readonly DateTime k_Ts = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
static ConsoleLogBuffer NewBuffer() => new ConsoleLogBuffer();
|
||||
|
||||
[Test]
|
||||
public void Add_AssignsIncreasingSequenceNumbers()
|
||||
{
|
||||
var buffer = NewBuffer();
|
||||
buffer.Add(LogType.Log, "a", null, k_Ts);
|
||||
buffer.Add(LogType.Log, "b", null, k_Ts);
|
||||
buffer.Add(LogType.Log, "c", null, k_Ts);
|
||||
|
||||
var entries = buffer.Query(-1, 100, ConsoleLogBuffer.SeverityLog).Entries;
|
||||
|
||||
CollectionAssert.AreEqual(new[] { "a", "b", "c" }, entries.Select(e => e.Message));
|
||||
CollectionAssert.AreEqual(new long[] { 1, 2, 3 }, entries.Select(e => e.Seq));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Query_Snapshot_ReturnsOldestFirst()
|
||||
{
|
||||
var buffer = NewBuffer();
|
||||
buffer.Add(LogType.Log, "first", null, k_Ts);
|
||||
buffer.Add(LogType.Log, "second", null, k_Ts);
|
||||
|
||||
var response = buffer.Query(-1, 100, ConsoleLogBuffer.SeverityLog);
|
||||
|
||||
Assert.AreEqual(2, response.Returned);
|
||||
Assert.AreEqual("first", response.Entries[0].Message);
|
||||
Assert.AreEqual("second", response.Entries[1].Message);
|
||||
Assert.AreEqual(2, response.Cursor);
|
||||
Assert.IsFalse(response.Dropped);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Query_LevelThreshold_IsMinimumSeverity()
|
||||
{
|
||||
var buffer = NewBuffer();
|
||||
buffer.Add(LogType.Log, "log", null, k_Ts);
|
||||
buffer.Add(LogType.Warning, "warn", null, k_Ts);
|
||||
buffer.Add(LogType.Error, "error", null, k_Ts);
|
||||
buffer.Add(LogType.Exception, "exception", null, k_Ts);
|
||||
|
||||
var warnAndUp = buffer.Query(-1, 100, ConsoleLogBuffer.SeverityWarn).Entries;
|
||||
CollectionAssert.AreEqual(new[] { "warn", "error", "exception" }, warnAndUp.Select(e => e.Message));
|
||||
|
||||
var errorOnly = buffer.Query(-1, 100, ConsoleLogBuffer.SeverityError).Entries;
|
||||
CollectionAssert.AreEqual(new[] { "error", "exception" }, errorOnly.Select(e => e.Message));
|
||||
|
||||
var all = buffer.Query(-1, 100, ConsoleLogBuffer.SeverityLog).Entries;
|
||||
Assert.AreEqual(4, all.Length);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Query_Tail_ReturnsMostRecentMatches()
|
||||
{
|
||||
var buffer = NewBuffer();
|
||||
for (int i = 0; i < 10; i++)
|
||||
buffer.Add(LogType.Log, $"m{i}", null, k_Ts);
|
||||
|
||||
var entries = buffer.Query(-1, 3, ConsoleLogBuffer.SeverityLog).Entries;
|
||||
|
||||
CollectionAssert.AreEqual(new[] { "m7", "m8", "m9" }, entries.Select(e => e.Message));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Query_Tail_AppliesAfterLevelFilter()
|
||||
{
|
||||
var buffer = NewBuffer();
|
||||
buffer.Add(LogType.Error, "e0", null, k_Ts);
|
||||
buffer.Add(LogType.Log, "l0", null, k_Ts);
|
||||
buffer.Add(LogType.Error, "e1", null, k_Ts);
|
||||
buffer.Add(LogType.Log, "l1", null, k_Ts);
|
||||
buffer.Add(LogType.Error, "e2", null, k_Ts);
|
||||
|
||||
// tail=2 over error-only matches => last two errors, not last two of all entries.
|
||||
var entries = buffer.Query(-1, 2, ConsoleLogBuffer.SeverityError).Entries;
|
||||
|
||||
CollectionAssert.AreEqual(new[] { "e1", "e2" }, entries.Select(e => e.Message));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Query_Since_ReturnsOnlyNewerEntries()
|
||||
{
|
||||
var buffer = NewBuffer();
|
||||
buffer.Add(LogType.Log, "a", null, k_Ts); // seq 1
|
||||
buffer.Add(LogType.Log, "b", null, k_Ts); // seq 2
|
||||
buffer.Add(LogType.Log, "c", null, k_Ts); // seq 3
|
||||
|
||||
var response = buffer.Query(2, 100, ConsoleLogBuffer.SeverityLog);
|
||||
|
||||
CollectionAssert.AreEqual(new[] { "c" }, response.Entries.Select(e => e.Message));
|
||||
Assert.AreEqual(3, response.Cursor);
|
||||
Assert.IsFalse(response.Dropped);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Query_SinceAtCursor_ReturnsEmptyButReportsCursor()
|
||||
{
|
||||
var buffer = NewBuffer();
|
||||
buffer.Add(LogType.Log, "a", null, k_Ts);
|
||||
buffer.Add(LogType.Log, "b", null, k_Ts);
|
||||
|
||||
var response = buffer.Query(2, 100, ConsoleLogBuffer.SeverityLog);
|
||||
|
||||
Assert.AreEqual(0, response.Returned);
|
||||
Assert.AreEqual(2, response.Cursor, "Cursor must still advance so a follow client resumes correctly");
|
||||
Assert.IsFalse(response.Dropped);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Query_SinceBeyondCursor_ReportsResetAndReturnsTail()
|
||||
{
|
||||
var buffer = NewBuffer();
|
||||
buffer.Add(LogType.Log, "a", null, k_Ts);
|
||||
|
||||
// long.MaxValue also pins the since+1 overflow the drop check would otherwise hit.
|
||||
var response = buffer.Query(long.MaxValue, 100, ConsoleLogBuffer.SeverityLog);
|
||||
|
||||
CollectionAssert.AreEqual(new[] { "a" }, response.Entries.Select(e => e.Message),
|
||||
"A cursor past the counter must be refused and the tail returned, not silently filter everything out");
|
||||
Assert.IsTrue(response.Reset);
|
||||
Assert.IsTrue(response.Dropped, "Reset implies the caller has a gap it cannot reason about");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Query_CursorReflectsLatestEvenWhenFilteredOut()
|
||||
{
|
||||
var buffer = NewBuffer();
|
||||
buffer.Add(LogType.Error, "boom", null, k_Ts); // seq 1
|
||||
buffer.Add(LogType.Log, "noise", null, k_Ts); // seq 2, filtered out by error level
|
||||
|
||||
var response = buffer.Query(1, 100, ConsoleLogBuffer.SeverityError);
|
||||
|
||||
Assert.AreEqual(0, response.Returned, "The only new entry is below the level threshold");
|
||||
Assert.AreEqual(2, response.Cursor, "Cursor advances past the filtered entry to avoid re-scanning it");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Eviction_DropsOldestAndFlagsDropped()
|
||||
{
|
||||
var buffer = NewBuffer();
|
||||
int total = ConsoleLogBuffer.Capacity + 50;
|
||||
for (int i = 0; i < total; i++)
|
||||
buffer.Add(LogType.Log, $"m{i}", null, k_Ts);
|
||||
|
||||
Assert.AreEqual(ConsoleLogBuffer.Capacity, buffer.Count, "Buffer should be capped at Capacity");
|
||||
|
||||
// since=1 points before the evicted window, so the consumer missed entries.
|
||||
var response = buffer.Query(1, ConsoleLogBuffer.Capacity, ConsoleLogBuffer.SeverityLog);
|
||||
Assert.IsTrue(response.Dropped, "Querying from an evicted cursor should set Dropped");
|
||||
|
||||
// The most recent entry is always present.
|
||||
var latest = buffer.Query(-1, 1, ConsoleLogBuffer.SeverityLog).Entries.Single();
|
||||
Assert.AreEqual($"m{total - 1}", latest.Message);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Dropped_FalseWhenSinceWithinRetainedWindow()
|
||||
{
|
||||
var buffer = NewBuffer();
|
||||
for (int i = 0; i < 10; i++)
|
||||
buffer.Add(LogType.Log, $"m{i}", null, k_Ts);
|
||||
|
||||
var response = buffer.Query(5, 100, ConsoleLogBuffer.SeverityLog);
|
||||
Assert.IsFalse(response.Dropped);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SaveAndLoad_RoundTripsEntriesAndCursor()
|
||||
{
|
||||
var path = Path.Combine("Temp", $"pipeline_console_test_{Guid.NewGuid():N}.json");
|
||||
try
|
||||
{
|
||||
var original = NewBuffer();
|
||||
original.Add(LogType.Log, "log", null, k_Ts);
|
||||
original.Add(LogType.Warning, "warn", "trace", k_Ts);
|
||||
original.Add(LogType.Error, "error", null, k_Ts);
|
||||
original.Save(path);
|
||||
|
||||
var restored = NewBuffer();
|
||||
Assert.IsTrue(restored.Load(path));
|
||||
|
||||
var entries = restored.Query(-1, 100, ConsoleLogBuffer.SeverityLog).Entries;
|
||||
CollectionAssert.AreEqual(new[] { "log", "warn", "error" }, entries.Select(e => e.Message));
|
||||
Assert.AreEqual(ConsoleLogBuffer.LevelWarn, entries[1].Level, "Severity must survive the round trip");
|
||||
Assert.AreEqual("trace", entries[1].StackTrace);
|
||||
|
||||
// Sequence continues monotonically after a restore.
|
||||
restored.Add(LogType.Log, "after", null, k_Ts);
|
||||
Assert.AreEqual(4, restored.Query(3, 100, ConsoleLogBuffer.SeverityLog).Entries.Single().Seq);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Load_MissingFile_ReturnsFalseAndLeavesBufferEmpty()
|
||||
{
|
||||
var buffer = NewBuffer();
|
||||
Assert.IsFalse(buffer.Load(Path.Combine("Temp", $"does_not_exist_{Guid.NewGuid():N}.json")));
|
||||
Assert.AreEqual(0, buffer.Count);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Load_ClampsRestoredEntriesToCapacityKeepingMostRecent()
|
||||
{
|
||||
var path = Path.Combine("Temp", $"pipeline_console_oversized_{Guid.NewGuid():N}.json");
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path) ?? "Temp");
|
||||
|
||||
var entries = Enumerable.Range(1, ConsoleLogBuffer.Capacity + 5)
|
||||
.Select(i => new ConsoleLogEntry
|
||||
{
|
||||
Seq = i,
|
||||
TimestampUtc = k_Ts,
|
||||
Level = ConsoleLogBuffer.LevelLog,
|
||||
Message = $"m{i}",
|
||||
StackTrace = string.Empty
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
File.WriteAllText(path, JsonConvert.SerializeObject(new
|
||||
{
|
||||
lastSeq = (long)(ConsoleLogBuffer.Capacity + 5),
|
||||
entries
|
||||
}));
|
||||
|
||||
var buffer = NewBuffer();
|
||||
Assert.IsTrue(buffer.Load(path));
|
||||
Assert.AreEqual(ConsoleLogBuffer.Capacity, buffer.Count);
|
||||
|
||||
var restored = buffer.Query(-1, ConsoleLogBuffer.Capacity, ConsoleLogBuffer.SeverityLog).Entries;
|
||||
Assert.AreEqual("m6", restored.First().Message);
|
||||
Assert.AreEqual($"m{ConsoleLogBuffer.Capacity + 5}", restored.Last().Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Clear_EmptiesEntriesButKeepsSequenceMonotonic()
|
||||
{
|
||||
var buffer = NewBuffer();
|
||||
buffer.Add(LogType.Log, "a", null, k_Ts);
|
||||
buffer.Add(LogType.Log, "b", null, k_Ts);
|
||||
|
||||
buffer.Clear();
|
||||
Assert.AreEqual(0, buffer.Count);
|
||||
|
||||
buffer.Add(LogType.Log, "c", null, k_Ts);
|
||||
Assert.AreEqual(3, buffer.LastSeq, "Sequence must not reset on Clear");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Add_IsThreadSafe()
|
||||
{
|
||||
var buffer = NewBuffer();
|
||||
const int threads = 8;
|
||||
const int perThread = 500;
|
||||
|
||||
var tasks = Enumerable.Range(0, threads).Select(t => Task.Run(() =>
|
||||
{
|
||||
for (int i = 0; i < perThread; i++)
|
||||
buffer.Add(LogType.Log, $"t{t}-{i}", null, k_Ts);
|
||||
})).ToArray();
|
||||
Task.WaitAll(tasks);
|
||||
|
||||
Assert.AreEqual(threads * perThread, buffer.LastSeq, "Every add must get a unique sequence number");
|
||||
Assert.AreEqual(ConsoleLogBuffer.Capacity, buffer.Count);
|
||||
|
||||
// All retained seqs are unique.
|
||||
var seqs = buffer.Query(-1, ConsoleLogBuffer.Capacity, ConsoleLogBuffer.SeverityLog)
|
||||
.Entries.Select(e => e.Seq).ToArray();
|
||||
Assert.AreEqual(seqs.Length, seqs.Distinct().Count(), "Sequence numbers must be unique");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Query_ForeignSession_ReportsResetAndReturnsTail()
|
||||
{
|
||||
var buffer = NewBuffer();
|
||||
buffer.SetSession("session-a");
|
||||
buffer.Add(LogType.Log, "a", null, k_Ts);
|
||||
buffer.Add(LogType.Log, "b", null, k_Ts);
|
||||
|
||||
var response = buffer.Query(1, 100, ConsoleLogBuffer.SeverityLog, "session-b");
|
||||
|
||||
CollectionAssert.AreEqual(new[] { "a", "b" }, response.Entries.Select(e => e.Message),
|
||||
"A cursor from another session names different entries, so it must be refused");
|
||||
Assert.IsTrue(response.Reset);
|
||||
Assert.AreEqual("session-a", response.Session);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Query_MatchingSession_HonorsCursor()
|
||||
{
|
||||
var buffer = NewBuffer();
|
||||
buffer.SetSession("session-a");
|
||||
buffer.Add(LogType.Log, "a", null, k_Ts);
|
||||
buffer.Add(LogType.Log, "b", null, k_Ts);
|
||||
|
||||
var response = buffer.Query(1, 100, ConsoleLogBuffer.SeverityLog, "session-a");
|
||||
|
||||
CollectionAssert.AreEqual(new[] { "b" }, response.Entries.Select(e => e.Message));
|
||||
Assert.IsFalse(response.Reset);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Query_ReportsRetainedCountsBeforeFilterAndTail()
|
||||
{
|
||||
var buffer = NewBuffer();
|
||||
buffer.Add(LogType.Log, "l", null, k_Ts);
|
||||
buffer.Add(LogType.Warning, "w", null, k_Ts);
|
||||
buffer.Add(LogType.Error, "e0", null, k_Ts);
|
||||
buffer.Add(LogType.Exception, "e1", null, k_Ts);
|
||||
|
||||
var response = buffer.Query(-1, 1, ConsoleLogBuffer.SeverityError);
|
||||
|
||||
Assert.AreEqual(1, response.Returned, "tail and level still apply to the entries");
|
||||
Assert.AreEqual(2, response.Counts.Error, "Counts describe what the buffer holds, not what was returned");
|
||||
Assert.AreEqual(1, response.Counts.Warn);
|
||||
Assert.AreEqual(1, response.Counts.Log);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Stats_ReportsCountsAndCursorWithoutEntries()
|
||||
{
|
||||
var buffer = NewBuffer();
|
||||
buffer.SetSession("session-a");
|
||||
buffer.Add(LogType.Log, "l", null, k_Ts);
|
||||
buffer.Add(LogType.Error, "e", null, k_Ts);
|
||||
|
||||
var stats = buffer.Stats();
|
||||
|
||||
Assert.IsEmpty(stats.Entries);
|
||||
Assert.AreEqual(2, stats.Cursor);
|
||||
Assert.AreEqual("session-a", stats.Session);
|
||||
Assert.AreEqual(1, stats.Counts.Error);
|
||||
Assert.AreEqual(1, stats.Counts.Log);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Add_Seeded_MarksEntryAndKeepsLogType()
|
||||
{
|
||||
var buffer = NewBuffer();
|
||||
buffer.Add(LogType.Exception, "threw", null, k_Ts, seeded: true);
|
||||
buffer.Add(LogType.Log, "live", null, k_Ts);
|
||||
|
||||
var entries = buffer.Query(-1, 100, ConsoleLogBuffer.SeverityLog).Entries;
|
||||
|
||||
Assert.IsTrue(entries[0].Seeded);
|
||||
Assert.AreEqual("Exception", entries[0].LogType, "The exact log type survives the severity collapse");
|
||||
Assert.AreEqual(ConsoleLogBuffer.LevelError, entries[0].Level);
|
||||
Assert.IsFalse(entries[1].Seeded);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Load_KeepsEntriesLoggedSinceSnapshot()
|
||||
{
|
||||
var path = Path.Combine("Temp", $"pipeline_console_merge_{Guid.NewGuid():N}.json");
|
||||
try
|
||||
{
|
||||
// The [NoAutoStaticsCleanup] case: the buffer survives the reload, so entries logged
|
||||
// after the snapshot are still in memory when the restore runs and must not be lost.
|
||||
var buffer = NewBuffer();
|
||||
buffer.Add(LogType.Log, "a", null, k_Ts);
|
||||
buffer.Add(LogType.Log, "b", null, k_Ts);
|
||||
buffer.Save(path);
|
||||
buffer.Add(LogType.Log, "c", null, k_Ts);
|
||||
buffer.Add(LogType.Log, "d", null, k_Ts);
|
||||
|
||||
Assert.IsTrue(buffer.Load(path));
|
||||
|
||||
var entries = buffer.Query(-1, 100, ConsoleLogBuffer.SeverityLog).Entries;
|
||||
CollectionAssert.AreEqual(new[] { "a", "b", "c", "d" }, entries.Select(e => e.Message));
|
||||
Assert.AreEqual(4, buffer.LastSeq);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Load_NeverLowersLastSeq()
|
||||
{
|
||||
var path = Path.Combine("Temp", $"pipeline_console_stale_{Guid.NewGuid():N}.json");
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory("Temp");
|
||||
File.WriteAllText(path, JsonConvert.SerializeObject(new
|
||||
{
|
||||
lastSeq = 5L,
|
||||
entries = Array.Empty<ConsoleLogEntry>()
|
||||
}));
|
||||
|
||||
var buffer = NewBuffer();
|
||||
for (int i = 0; i < 100; i++)
|
||||
buffer.Add(LogType.Log, $"m{i}", null, k_Ts);
|
||||
|
||||
Assert.IsTrue(buffer.Load(path));
|
||||
Assert.AreEqual(100, buffer.LastSeq, "A stale snapshot must not rewind cursors already handed out");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RaiseLastSeq_RaisesButNeverLowers()
|
||||
{
|
||||
var buffer = NewBuffer();
|
||||
buffer.Add(LogType.Log, "a", null, k_Ts);
|
||||
|
||||
buffer.RaiseLastSeq(50);
|
||||
Assert.AreEqual(50, buffer.LastSeq);
|
||||
|
||||
buffer.RaiseLastSeq(10);
|
||||
Assert.AreEqual(50, buffer.LastSeq);
|
||||
|
||||
buffer.Add(LogType.Log, "b", null, k_Ts);
|
||||
Assert.AreEqual(51, buffer.Query(50, 100, ConsoleLogBuffer.SeverityLog).Entries.Single().Seq);
|
||||
}
|
||||
|
||||
// Two shapes a domain reload takes: statics wiped, so the restore lands in a fresh buffer;
|
||||
// or statics kept ([NoAutoStaticsCleanup]), so the buffer that survives already holds the
|
||||
// entries logged after the snapshot was written. The second is where drift compounds.
|
||||
[TestCase(false, TestName = "RepeatedReloadCycles_LoseNoEntriesAndKeepSeqMonotonic(StaticsWiped)")]
|
||||
[TestCase(true, TestName = "RepeatedReloadCycles_LoseNoEntriesAndKeepSeqMonotonic(StaticsSurvive)")]
|
||||
public void RepeatedReloadCycles_LoseNoEntriesAndKeepSeqMonotonic(bool staticsSurvive)
|
||||
{
|
||||
// The agentic loop drives a domain reload per recompile, and the reported corruption was
|
||||
// drift that only showed after several. One cycle proves nothing; this walks twenty,
|
||||
// logging on both sides of every save.
|
||||
var path = Path.Combine("Temp", $"pipeline_console_cycles_{Guid.NewGuid():N}.json");
|
||||
try
|
||||
{
|
||||
var buffer = NewBuffer();
|
||||
var expected = new List<string>();
|
||||
var cursors = new List<long>();
|
||||
|
||||
for (var cycle = 0; cycle < 20; cycle++)
|
||||
{
|
||||
var before = $"c{cycle}-before";
|
||||
buffer.Add(LogType.Log, before, null, k_Ts);
|
||||
expected.Add(before);
|
||||
|
||||
buffer.Save(path);
|
||||
|
||||
// Logged after the snapshot: only a surviving buffer still holds this when the
|
||||
// restore runs, and the restore must not discard it.
|
||||
var between = $"c{cycle}-between";
|
||||
buffer.Add(LogType.Log, between, null, k_Ts);
|
||||
expected.Add(between);
|
||||
|
||||
if (!staticsSurvive)
|
||||
{
|
||||
// A wiped domain loses the entry logged after the save, exactly as the real
|
||||
// one does — it was never persisted.
|
||||
expected.Remove(between);
|
||||
buffer = NewBuffer();
|
||||
}
|
||||
|
||||
buffer.Load(path);
|
||||
|
||||
var after = $"c{cycle}-after";
|
||||
buffer.Add(LogType.Log, after, null, k_Ts);
|
||||
expected.Add(after);
|
||||
|
||||
cursors.Add(buffer.LastSeq);
|
||||
}
|
||||
|
||||
var entries = buffer.Query(-1, 0, ConsoleLogBuffer.SeverityLog).Entries;
|
||||
CollectionAssert.AreEqual(expected, entries.Select(e => e.Message),
|
||||
"No cycle may drop or duplicate an entry");
|
||||
|
||||
var seqs = entries.Select(e => e.Seq).ToArray();
|
||||
CollectionAssert.AreEqual(seqs.OrderBy(x => x), seqs, "Sequence numbers must stay ordered");
|
||||
Assert.AreEqual(seqs.Length, seqs.Distinct().Count(), "Sequence numbers must stay unique");
|
||||
CollectionAssert.AreEqual(cursors.OrderBy(x => x), cursors,
|
||||
"The cursor must never go backwards across a reload");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EditorRestart_RefusesACursorFromTheSessionBefore()
|
||||
{
|
||||
// The likeliest real-world trigger: Unity deletes Temp/ at every editor launch, so the
|
||||
// snapshot is gone and the counter restarts at zero while a client still holds a cursor
|
||||
// from the session before. That cursor is far ahead of the new counter, so it used to
|
||||
// filter out every entry and read as "nothing new" indefinitely.
|
||||
var path = Path.Combine("Temp", $"pipeline_console_restart_{Guid.NewGuid():N}.json");
|
||||
try
|
||||
{
|
||||
var firstSession = NewBuffer();
|
||||
firstSession.SetSession("session-before-restart");
|
||||
for (var i = 0; i < 500; i++)
|
||||
firstSession.Add(LogType.Log, $"old{i}", null, k_Ts);
|
||||
firstSession.Save(path);
|
||||
|
||||
var clientCursor = firstSession.LastSeq;
|
||||
var clientSession = firstSession.Session;
|
||||
Assert.AreEqual(500, clientCursor);
|
||||
|
||||
// The restart: Temp/ is wiped, so there is nothing to restore, and the new session
|
||||
// gets a new identifier and a counter starting from zero.
|
||||
File.Delete(path);
|
||||
var afterRestart = NewBuffer();
|
||||
afterRestart.SetSession("session-after-restart");
|
||||
Assert.IsFalse(afterRestart.Load(path), "The snapshot is gone with Temp/");
|
||||
Assert.AreEqual(0, afterRestart.LastSeq);
|
||||
|
||||
afterRestart.Add(LogType.Error, "compile error after restart", null, k_Ts);
|
||||
|
||||
var response = afterRestart.Query(clientCursor, 100, ConsoleLogBuffer.SeverityLog, clientSession);
|
||||
|
||||
CollectionAssert.AreEqual(new[] { "compile error after restart" },
|
||||
response.Entries.Select(e => e.Message),
|
||||
"The stale cursor must be refused and the entry served, not silently filtered out");
|
||||
Assert.IsTrue(response.Reset);
|
||||
Assert.IsTrue(response.Dropped);
|
||||
Assert.AreEqual("session-after-restart", response.Session,
|
||||
"The response must hand back the session the client should adopt");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SeverityFromLevelName_ParsesNamesAndAliases()
|
||||
{
|
||||
Assert.AreEqual(ConsoleLogBuffer.SeverityError, ConsoleLogBuffer.SeverityFromLevelName("error"));
|
||||
Assert.AreEqual(ConsoleLogBuffer.SeverityError, ConsoleLogBuffer.SeverityFromLevelName("ERR"));
|
||||
Assert.AreEqual(ConsoleLogBuffer.SeverityWarn, ConsoleLogBuffer.SeverityFromLevelName("warn"));
|
||||
Assert.AreEqual(ConsoleLogBuffer.SeverityWarn, ConsoleLogBuffer.SeverityFromLevelName("Warning"));
|
||||
Assert.AreEqual(ConsoleLogBuffer.SeverityLog, ConsoleLogBuffer.SeverityFromLevelName("log"));
|
||||
Assert.AreEqual(ConsoleLogBuffer.SeverityLog, ConsoleLogBuffer.SeverityFromLevelName("nonsense"));
|
||||
Assert.AreEqual(ConsoleLogBuffer.SeverityLog, ConsoleLogBuffer.SeverityFromLevelName(null));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 524e94027ab20453a5442be5e73f87a1
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Threading.Tasks;
|
||||
using NUnit.Framework;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Unity.Pipeline;
|
||||
using Unity.Pipeline.Editor;
|
||||
using Unity.Pipeline.Security;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
class DialogEndpointTests
|
||||
{
|
||||
sealed class DialogTestServer : EditorPipelineServer
|
||||
{
|
||||
protected override bool WritesDescriptor => false;
|
||||
protected override (int basePort, int maxPort) GetPortRange() => (7850, 7899);
|
||||
protected override string GetToken() => SecurityTokenManager.GetOrCreateToken();
|
||||
protected override bool IsSettled => true;
|
||||
}
|
||||
|
||||
DialogTestServer m_Server;
|
||||
Unity.Pipeline.Tests.Runtime.PipelineClient m_Client;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
m_Server = new DialogTestServer();
|
||||
m_Server.Start();
|
||||
m_Client = new Unity.Pipeline.Tests.Runtime.PipelineClient(m_Server);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
m_Client?.Dispose();
|
||||
m_Server?.Stop();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ApiDialog_NoDialogOpen_ReportsInactiveEmptyList()
|
||||
{
|
||||
var http = await m_Client.GetHttpAsync("/api/dialog");
|
||||
var json = JObject.Parse(await http.Content.ReadAsStringAsync());
|
||||
|
||||
Assert.IsFalse(json["active"].ToObject<bool>());
|
||||
Assert.IsEmpty((JArray)json["dialogs"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ApiDialog_OneDialogOpen_ReportsItsFields()
|
||||
{
|
||||
m_Server.Dialogs.OnShown(7, "native", "Save changes?", "You have unsaved changes.",
|
||||
new[] { "Save", "Cancel" }, "Warning", System.DateTime.UtcNow);
|
||||
|
||||
var http = await m_Client.GetHttpAsync("/api/dialog");
|
||||
var json = JObject.Parse(await http.Content.ReadAsStringAsync());
|
||||
|
||||
Assert.IsTrue(json["active"].ToObject<bool>());
|
||||
var dialog = (JObject)((JArray)json["dialogs"])[0];
|
||||
Assert.AreEqual(7, dialog["id"].ToObject<int>());
|
||||
Assert.AreEqual("native", dialog["source"].ToString());
|
||||
Assert.AreEqual("Save changes?", dialog["title"].ToString());
|
||||
Assert.AreEqual("Warning", dialog["level"].ToString());
|
||||
CollectionAssert.AreEqual(new[] { "Save", "Cancel" }, dialog["buttons"].ToObject<string[]>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ApiDialog_DismissedDialog_NoLongerReportedAsActive()
|
||||
{
|
||||
m_Server.Dialogs.OnShown(7, "native", "Title", "Message", new[] { "OK" }, "Info", System.DateTime.UtcNow);
|
||||
m_Server.Dialogs.OnDismissed(7, System.DateTime.UtcNow);
|
||||
|
||||
var http = await m_Client.GetHttpAsync("/api/dialog");
|
||||
var json = JObject.Parse(await http.Content.ReadAsStringAsync());
|
||||
|
||||
Assert.IsFalse(json["active"].ToObject<bool>());
|
||||
Assert.IsEmpty((JArray)json["dialogs"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 46890cbfc14844928e0d9f5af8a0a6af
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetVersion: 1
|
||||
@@ -0,0 +1,118 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
class DialogStateTrackerTests
|
||||
{
|
||||
[Test]
|
||||
public void NoDialogShown_IsDialogOpen_False_AndCurrentlyOpen_Empty()
|
||||
{
|
||||
var tracker = new DialogStateTracker();
|
||||
|
||||
Assert.IsFalse(tracker.IsDialogOpen);
|
||||
Assert.IsEmpty(tracker.CurrentlyOpen);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnShown_MakesIsDialogOpen_True_AndAppearsInCurrentlyOpen()
|
||||
{
|
||||
var tracker = new DialogStateTracker();
|
||||
var openedAt = DateTime.UtcNow;
|
||||
|
||||
tracker.OnShown(1, "native", "Title", "Message", new[] { "OK" }, "Warning", openedAt);
|
||||
|
||||
Assert.IsTrue(tracker.IsDialogOpen);
|
||||
var open = tracker.CurrentlyOpen.Single();
|
||||
Assert.AreEqual(1, open.Id);
|
||||
Assert.AreEqual("native", open.Source);
|
||||
Assert.AreEqual("Title", open.Title);
|
||||
Assert.AreEqual("Message", open.Message);
|
||||
CollectionAssert.AreEqual(new[] { "OK" }, open.Buttons);
|
||||
Assert.AreEqual("Warning", open.Level);
|
||||
Assert.AreEqual(openedAt, open.OpenedAtUtc);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnDismissed_RemovesFromCurrentlyOpen()
|
||||
{
|
||||
var tracker = new DialogStateTracker();
|
||||
tracker.OnShown(1, "native", "Title", "Message", new[] { "OK" }, "Info", DateTime.UtcNow);
|
||||
|
||||
tracker.OnDismissed(1, DateTime.UtcNow);
|
||||
|
||||
Assert.IsFalse(tracker.IsDialogOpen);
|
||||
Assert.IsEmpty(tracker.CurrentlyOpen);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TwoDialogsOpenBeforeEitherDismissed_BothAppearInCurrentlyOpen_DismissingOneLeavesOtherActive()
|
||||
{
|
||||
var tracker = new DialogStateTracker();
|
||||
tracker.OnShown(1, "native", "A", "a", new[] { "OK" }, "Info", DateTime.UtcNow);
|
||||
tracker.OnShown(2, "managed", "B", null, null, null, DateTime.UtcNow);
|
||||
|
||||
Assert.AreEqual(2, tracker.CurrentlyOpen.Count);
|
||||
|
||||
tracker.OnDismissed(1, DateTime.UtcNow);
|
||||
|
||||
Assert.IsTrue(tracker.IsDialogOpen);
|
||||
var remaining = tracker.CurrentlyOpen.Single();
|
||||
Assert.AreEqual(2, remaining.Id);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnDismissed_WithUnknownId_IsNoOp()
|
||||
{
|
||||
var tracker = new DialogStateTracker();
|
||||
|
||||
Assert.DoesNotThrow(() => tracker.OnDismissed(999, DateTime.UtcNow));
|
||||
Assert.IsFalse(tracker.IsDialogOpen);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EventsSince_IncludesDismissedDialogsOpenedAtOrAfterTimestamp()
|
||||
{
|
||||
var tracker = new DialogStateTracker();
|
||||
var beforeCommand = DateTime.UtcNow;
|
||||
var openedAt = beforeCommand.AddMilliseconds(10);
|
||||
tracker.OnShown(1, "native", "Title", "Message", new[] { "OK" }, "Info", openedAt);
|
||||
tracker.OnDismissed(1, openedAt.AddMilliseconds(5));
|
||||
|
||||
var events = tracker.EventsSince(beforeCommand);
|
||||
|
||||
var found = events.Single();
|
||||
Assert.AreEqual(1, found.Id);
|
||||
Assert.IsTrue(found.DismissedAtUtc.HasValue);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EventsSince_ExcludesDialogsOpenedBeforeTheTimestamp()
|
||||
{
|
||||
var tracker = new DialogStateTracker();
|
||||
var openedAt = DateTime.UtcNow;
|
||||
tracker.OnShown(1, "native", "Title", "Message", new[] { "OK" }, "Info", openedAt);
|
||||
tracker.OnDismissed(1, openedAt.AddMilliseconds(5));
|
||||
|
||||
var events = tracker.EventsSince(openedAt.AddSeconds(1));
|
||||
|
||||
Assert.IsEmpty(events);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EventsSince_IncludesStillOpenDialogs()
|
||||
{
|
||||
var tracker = new DialogStateTracker();
|
||||
var beforeCommand = DateTime.UtcNow;
|
||||
tracker.OnShown(1, "native", "Title", "Message", new[] { "OK" }, "Info", beforeCommand.AddMilliseconds(5));
|
||||
|
||||
var events = tracker.EventsSince(beforeCommand);
|
||||
|
||||
var found = events.Single();
|
||||
Assert.AreEqual(1, found.Id);
|
||||
Assert.IsFalse(found.DismissedAtUtc.HasValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7b42c8c2fdf4495c9b86bbfbec8f2a9f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetVersion: 1
|
||||
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using NUnit.Framework;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Unity.Pipeline;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Editor;
|
||||
using Unity.Pipeline.Security;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Test-only command that raises a dialog shown+dismissed pair on a caller-supplied tracker
|
||||
/// from inside its own handler — lets DialogsDuringExecutionTests exercise the real
|
||||
/// execStartUtc-scoped attach logic without needing a real modal dialog.
|
||||
/// </summary>
|
||||
static class SimulateDialogDuringExecCommand
|
||||
{
|
||||
internal static DialogStateTracker TargetTracker;
|
||||
|
||||
[CliCommand("test_simulate_dialog_during_exec", "Test-only: raises a dialog shown+dismissed pair during its own execution", MainThreadRequired = true)]
|
||||
public static bool Run()
|
||||
{
|
||||
TargetTracker?.OnShown(42, "native", "Mid-command dialog", "msg", new[] { "OK" }, "Info", DateTime.UtcNow);
|
||||
TargetTracker?.OnDismissed(42, DateTime.UtcNow);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class DialogsDuringExecutionTests
|
||||
{
|
||||
sealed class DialogTestServer : EditorPipelineServer
|
||||
{
|
||||
protected override bool WritesDescriptor => false;
|
||||
protected override (int basePort, int maxPort) GetPortRange() => (7850, 7899);
|
||||
protected override string GetToken() => SecurityTokenManager.GetOrCreateToken();
|
||||
protected override bool IsSettled => true;
|
||||
}
|
||||
|
||||
DialogTestServer m_Server;
|
||||
Unity.Pipeline.Tests.Runtime.PipelineClient m_Client;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
|
||||
m_Server = new DialogTestServer();
|
||||
m_Server.Start();
|
||||
m_Client = new Unity.Pipeline.Tests.Runtime.PipelineClient(m_Server);
|
||||
SimulateDialogDuringExecCommand.TargetTracker = m_Server.Dialogs;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
SimulateDialogDuringExecCommand.TargetTracker = null;
|
||||
m_Client?.Dispose();
|
||||
m_Server?.Stop();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ApiExec_NoDialogRaised_OmitsDialogsDuringExecutionField()
|
||||
{
|
||||
var response = await m_Client.ExecuteCommandAsync("editor_status", new { });
|
||||
|
||||
Assert.IsTrue(response.IsSuccess);
|
||||
Assert.IsNull(response.JsonResponse["dialogsDuringExecution"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ApiExec_DialogRaisedAndDismissedInsideHandler_AttachesIt()
|
||||
{
|
||||
var response = await m_Client.ExecuteCommandAsync("test_simulate_dialog_during_exec", new { });
|
||||
|
||||
Assert.IsTrue(response.IsSuccess);
|
||||
var attached = (JArray)response.JsonResponse["dialogsDuringExecution"];
|
||||
Assert.IsNotNull(attached, "A dialog raised inside the command's own handler must be attached");
|
||||
Assert.AreEqual(42, attached[0]["id"].ToObject<int>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ApiExec_DialogRaisedLongBeforeCommand_DoesNotLeakIntoResponse()
|
||||
{
|
||||
m_Server.Dialogs.OnShown(1, "native", "Old dialog", "msg", new[] { "OK" }, "Info",
|
||||
System.DateTime.UtcNow.AddMinutes(-5));
|
||||
m_Server.Dialogs.OnDismissed(1, System.DateTime.UtcNow.AddMinutes(-5));
|
||||
|
||||
var response = await m_Client.ExecuteCommandAsync("editor_status", new { });
|
||||
|
||||
Assert.IsTrue(response.IsSuccess);
|
||||
Assert.IsNull(response.JsonResponse["dialogsDuringExecution"],
|
||||
"A dialog that opened well before this call started must not leak into its response");
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1c0002bd3c824e128c59d61d2739fb41
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetVersion: 1
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user