Add Unity Pipeline package support

This commit is contained in:
ud18010
2026-07-31 17:34:04 +08:00
parent d1a526094e
commit 786b7ffff9
590 changed files with 51995 additions and 2 deletions
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: de3774b0893944d8ac6871cbcaa631ab
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,211 @@
using System.Linq;
using NUnit.Framework;
using Newtonsoft.Json.Linq;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Editor.Commands.Animation;
using Unity.Pipeline.Models;
using UnityEditor;
using UnityEngine;
namespace Unity.Pipeline.Tests.Editor.Animation
{
/// <summary>
/// Tests for CLI-214 Group A (animation clips): create clip with frame rate + loop, set/get/remove
/// float curves, the overwrite-not-duplicate behaviour, the destructive remove guard, dry_run, and
/// the sandbox guard. Assets are generated in-test under a throwaway root and cleaned up.
/// </summary>
public class AnimationClipCommandsTests
{
private const string Root = "Assets/__CLI214AnimTest";
[TearDown]
public void TearDown()
{
ProjectPaths.ResetAuthoringRoot();
if (AssetDatabase.IsValidFolder(Root))
{
AssetDatabase.DeleteAsset(Root);
AssetDatabase.Refresh();
}
}
private static ObjectRef PathRef(string path) => new ObjectRef { Path = path };
private static JArray TwoKeys() => new JArray
{
new JObject { ["time"] = 0f, ["value"] = 0f },
new JObject { ["time"] = 1f, ["value"] = 5f }
};
[Test]
public void CreateAnimationClip_SetsFrameRateAndLoop()
{
var path = Root + "/Clips/Walk.anim";
var result = AnimationClipCommands.CreateAnimationClip(path, frameRate: 30f, loop: true);
Assert.AreEqual(path, result.AssetPath);
var loaded = AssetDatabase.LoadAssetAtPath<AnimationClip>(path);
Assert.IsNotNull(loaded, "Clip should exist on disk");
Assert.AreEqual(30f, loaded.frameRate, 0.001f);
var info = AnimationClipCommands.GetAnimationClip(PathRef(path));
Assert.AreEqual(30f, info.FrameRate, 0.001f);
Assert.IsTrue(info.Loop, "loop should be reflected by get_animation_clip");
}
[Test]
public void CreateAnimationClip_OverwriteWithoutConfirm_Throws()
{
var path = Root + "/Clips/Dup.anim";
AnimationClipCommands.CreateAnimationClip(path);
Assert.Throws<System.ArgumentException>(() => AnimationClipCommands.CreateAnimationClip(path));
Assert.DoesNotThrow(() => AnimationClipCommands.CreateAnimationClip(path, confirm: true));
}
[Test]
public void CreateAnimationClip_OutOfProject_Throws()
{
Assert.Throws<System.ArgumentException>(() => AnimationClipCommands.CreateAnimationClip("../Outside.anim"));
Assert.Throws<System.ArgumentException>(() => AnimationClipCommands.CreateAnimationClip("Assets/../Outside.anim"));
}
[Test]
public void CreateAnimationClip_DryRun_WritesNothing()
{
var path = Root + "/Clips/DryRun.anim";
var result = AnimationClipCommands.CreateAnimationClip(path, dryRun: true);
Assert.AreEqual(path, result.AssetPath);
Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(path), "dry_run must not write a clip");
}
[Test]
public void SetAnimationCurve_AddsCurveWithKeys()
{
var path = Root + "/Clips/Curve.anim";
AnimationClipCommands.CreateAnimationClip(path);
var result = AnimationClipCommands.SetAnimationCurve(
PathRef(path), path: "", type: "Transform", property: "m_LocalPosition.x", keys: TwoKeys());
Assert.AreEqual(2, result.KeyCount);
Assert.AreEqual("m_LocalPosition.x", result.Binding.Property);
var info = AnimationClipCommands.GetAnimationClip(PathRef(path), includeKeys: true);
Assert.AreEqual(1, info.Bindings.Count);
var binding = info.Bindings[0];
Assert.AreEqual("Transform", binding.Type);
Assert.AreEqual("m_LocalPosition.x", binding.Property);
Assert.AreEqual(2, binding.KeyCount);
Assert.IsNotNull(binding.Keys);
Assert.AreEqual(2, binding.Keys.Count);
Assert.AreEqual(0f, binding.Keys[0].Value, 0.001f);
Assert.AreEqual(5f, binding.Keys[1].Value, 0.001f);
}
[Test]
public void SetAnimationCurve_ReplaceExisting_Overwrites()
{
var path = Root + "/Clips/Replace.anim";
AnimationClipCommands.CreateAnimationClip(path);
AnimationClipCommands.SetAnimationCurve(PathRef(path), "", "Transform", "m_LocalPosition.x", TwoKeys());
var threeKeys = new JArray
{
new JObject { ["time"] = 0f, ["value"] = 0f },
new JObject { ["time"] = 0.5f, ["value"] = 2f },
new JObject { ["time"] = 1f, ["value"] = 4f }
};
var result = AnimationClipCommands.SetAnimationCurve(PathRef(path), "", "Transform", "m_LocalPosition.x", threeKeys);
Assert.AreEqual(3, result.KeyCount);
var info = AnimationClipCommands.GetAnimationClip(PathRef(path));
// Same binding => not duplicated; the single binding now has 3 keys.
Assert.AreEqual(1, info.Bindings.Count, "Replacing a binding must not duplicate it");
Assert.AreEqual(3, info.Bindings[0].KeyCount);
}
[Test]
public void SetAnimationCurve_DryRun_WritesNothing()
{
var path = Root + "/Clips/DryCurve.anim";
AnimationClipCommands.CreateAnimationClip(path);
var result = AnimationClipCommands.SetAnimationCurve(
PathRef(path), "", "Transform", "m_LocalPosition.x", TwoKeys(), dryRun: true);
Assert.AreEqual(2, result.KeyCount);
var info = AnimationClipCommands.GetAnimationClip(PathRef(path));
Assert.AreEqual(0, info.Bindings.Count, "dry_run must not write a curve");
}
[Test]
public void RemoveAnimationCurve_WithoutConfirm_Throws()
{
var path = Root + "/Clips/Remove.anim";
AnimationClipCommands.CreateAnimationClip(path);
AnimationClipCommands.SetAnimationCurve(PathRef(path), "", "Transform", "m_LocalPosition.x", TwoKeys());
Assert.Throws<System.ArgumentException>(
() => AnimationClipCommands.RemoveAnimationCurve(PathRef(path), "", "Transform", "m_LocalPosition.x"),
"remove without confirm should be rejected");
var info = AnimationClipCommands.GetAnimationClip(PathRef(path));
Assert.AreEqual(1, info.Bindings.Count, "binding should survive a refused remove");
}
[Test]
public void RemoveAnimationCurve_WithConfirm_Removes()
{
var path = Root + "/Clips/Remove2.anim";
AnimationClipCommands.CreateAnimationClip(path);
AnimationClipCommands.SetAnimationCurve(PathRef(path), "", "Transform", "m_LocalPosition.x", TwoKeys());
AnimationClipCommands.RemoveAnimationCurve(PathRef(path), "", "Transform", "m_LocalPosition.x", confirm: true);
var info = AnimationClipCommands.GetAnimationClip(PathRef(path));
Assert.AreEqual(0, info.Bindings.Count, "binding should be gone after a confirmed remove");
}
[Test]
public void SetAnimationCurve_UnknownType_Throws()
{
var path = Root + "/Clips/BadType.anim";
AnimationClipCommands.CreateAnimationClip(path);
Assert.Throws<System.ArgumentException>(
() => AnimationClipCommands.SetAnimationCurve(PathRef(path), "", "NotARealComponent__CLI214", "m_X", TwoKeys()));
}
[Test]
public void SetAnimationCurve_ViaClient_Succeeds()
{
var path = Root + "/ViaClient/Curve.anim";
AnimationClipCommands.CreateAnimationClip(path);
using (var server = new PipelineTestServer())
{
var response = server.Execute("set_animation_curve", new
{
clip = new { path },
type = "Transform",
property = "m_LocalPosition.x",
keys = new object[]
{
new { time = 0f, value = 0f },
new { time = 1f, value = 5f }
}
});
Assert.IsTrue(response.IsSuccess, $"set_animation_curve should succeed: {response.Error}");
}
var info = AnimationClipCommands.GetAnimationClip(PathRef(path));
Assert.AreEqual(1, info.Bindings.Count);
Assert.AreEqual(2, info.Bindings[0].KeyCount);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 613a4bb343db4a4cb96ae6fef8195425
@@ -0,0 +1,261 @@
using NUnit.Framework;
using Newtonsoft.Json.Linq;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Editor.Commands.Animation;
using Unity.Pipeline.Models;
using UnityEditor;
using UnityEngine;
namespace Unity.Pipeline.Tests.Editor.Animation
{
/// <summary>
/// Tests for CLI-214 Group B (animator controllers): create controller (Base Layer, no states),
/// add parameter (and duplicate => duplicate_parameter), add layer, add state (with motion + as
/// default), add transition (with a matching condition), and the validation failures (missing
/// parameter, mode/type mismatch). Assets are generated in-test and cleaned up.
/// </summary>
public class AnimatorControllerCommandsTests
{
private const string Root = "Assets/__CLI214CtrlTest";
[TearDown]
public void TearDown()
{
ProjectPaths.ResetAuthoringRoot();
if (AssetDatabase.IsValidFolder(Root))
{
AssetDatabase.DeleteAsset(Root);
AssetDatabase.Refresh();
}
}
private static ObjectRef PathRef(string path) => new ObjectRef { Path = path };
private string CreateController(string name)
{
var path = $"{Root}/{name}.controller";
AnimatorControllerCommands.CreateAnimatorController(path);
return path;
}
[Test]
public void CreateController_HasOneBaseLayerNoStates()
{
var path = CreateController("Empty");
var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path));
Assert.AreEqual(1, info.Layers.Count, "A new controller should have exactly one (Base) layer");
Assert.AreEqual(0, info.Layers[0].States.Count, "Base Layer should start with no states");
Assert.AreEqual(0, info.Parameters.Count);
}
[Test]
public void AddParameter_Float_AndDuplicate()
{
var path = CreateController("Params");
var added = AnimatorControllerCommands.AddAnimatorParameter(PathRef(path), "Speed", "Float", new JValue(0f));
Assert.IsInstanceOf<AddParameterResult>(added);
var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path));
Assert.AreEqual(1, info.Parameters.Count);
Assert.AreEqual("Speed", info.Parameters[0].Name);
Assert.AreEqual("Float", info.Parameters[0].ParamType);
// Duplicate name => structured error, not exception.
var dup = AnimatorControllerCommands.AddAnimatorParameter(PathRef(path), "Speed", "Float");
Assert.IsInstanceOf<ErrorResult>(dup);
Assert.AreEqual("duplicate_parameter", ((ErrorResult)dup).Code);
}
[Test]
public void AddLayer_AppendsLayer()
{
var path = CreateController("Layers");
var added = AnimatorControllerCommands.AddAnimatorLayer(PathRef(path), "Upper", weight: 0.5f, blendingMode: "Additive");
Assert.IsInstanceOf<AddLayerResult>(added);
Assert.AreEqual(1, ((AddLayerResult)added).Layer.Index);
var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path));
Assert.AreEqual(2, info.Layers.Count);
Assert.AreEqual("Upper", info.Layers[1].Name);
}
[Test]
public void AddState_WithMotionAndDefault()
{
var path = CreateController("States");
// A clip to use as the state motion.
var clipPath = Root + "/Idle.anim";
AnimationClipCommands.CreateAnimationClip(clipPath);
var added = AnimatorControllerCommands.AddAnimatorState(
PathRef(path), layer: null, name: "Idle", motion: PathRef(clipPath), isDefault: true);
Assert.IsInstanceOf<AddStateResult>(added);
var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path));
Assert.AreEqual(1, info.Layers[0].States.Count);
var state = info.Layers[0].States[0];
Assert.AreEqual("Idle", state.Name);
Assert.IsTrue(state.IsDefault, "Idle should be the layer default");
Assert.IsNotNull(state.Motion, "Idle should have a non-null motion");
Assert.AreEqual("Idle", info.Layers[0].DefaultState);
}
[Test]
public void AddState_UnknownLayerName_ReturnsLayerNotFound()
{
var path = CreateController("BadLayer");
var result = AnimatorControllerCommands.AddAnimatorState(
PathRef(path), layer: new JValue("NoSuchLayer"), name: "S");
Assert.IsInstanceOf<ErrorResult>(result);
Assert.AreEqual("layer_not_found", ((ErrorResult)result).Code);
}
[Test]
public void AddTransition_WithCondition()
{
var path = CreateController("Transitions");
AnimatorControllerCommands.AddAnimatorParameter(PathRef(path), "Speed", "Float", new JValue(0f));
AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Idle");
AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Run");
var conditions = new JArray
{
new JObject { ["parameter"] = "Speed", ["mode"] = "Greater", ["threshold"] = 0.1f }
};
var added = AnimatorControllerCommands.AddAnimatorTransition(
PathRef(path), null, "Idle", "Run", conditions);
Assert.IsInstanceOf<AddTransitionResult>(added);
Assert.AreEqual(1, ((AddTransitionResult)added).Transition.ConditionCount);
var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path));
var transitions = info.Layers[0].Transitions;
Assert.AreEqual(1, transitions.Count);
Assert.AreEqual("Idle", transitions[0].From);
Assert.AreEqual("Run", transitions[0].To);
Assert.AreEqual(1, transitions[0].Conditions.Count);
Assert.AreEqual("Speed", transitions[0].Conditions[0].Parameter);
Assert.AreEqual("Greater", transitions[0].Conditions[0].Mode);
}
[Test]
public void AddTransition_UnknownParameter_ThrowsAndWritesNothing()
{
var path = CreateController("BadParam");
AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Idle");
AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Run");
var conditions = new JArray
{
new JObject { ["parameter"] = "Ghost", ["mode"] = "Greater", ["threshold"] = 0.1f }
};
var ex = Assert.Throws<System.ArgumentException>(
() => AnimatorControllerCommands.AddAnimatorTransition(PathRef(path), null, "Idle", "Run", conditions));
StringAssert.Contains("Ghost", ex.Message, "error should name the offending parameter");
var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path));
Assert.AreEqual(0, info.Layers[0].Transitions.Count, "nothing should be written on a bad condition");
}
[Test]
public void AddTransition_ModeTypeMismatch_Throws()
{
var path = CreateController("Mismatch");
AnimatorControllerCommands.AddAnimatorParameter(PathRef(path), "Speed", "Float", new JValue(0f));
AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Idle");
AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Run");
// "If" is only valid for Bool/Trigger, not a Float parameter.
var conditions = new JArray
{
new JObject { ["parameter"] = "Speed", ["mode"] = "If" }
};
Assert.Throws<System.ArgumentException>(
() => AnimatorControllerCommands.AddAnimatorTransition(PathRef(path), null, "Idle", "Run", conditions));
}
[Test]
public void AddState_FractionalLayerIndex_ReturnsLayerNotFound()
{
var path = CreateController("FractionalLayer");
// 0.9 must NOT be silently rounded to layer 1; a non-integer index is rejected.
var result = AnimatorControllerCommands.AddAnimatorState(
PathRef(path), layer: new JValue(0.9d), name: "S");
Assert.IsInstanceOf<ErrorResult>(result);
Assert.AreEqual("layer_not_found", ((ErrorResult)result).Code);
}
[Test]
public void AddState_IntegerValuedFloatLayer_Resolves()
{
var path = CreateController("IntFloatLayer");
// 0.0 is an integer-valued float and resolves to the Base Layer (index 0).
var added = AnimatorControllerCommands.AddAnimatorState(
PathRef(path), layer: new JValue(0d), name: "Idle");
Assert.IsInstanceOf<AddStateResult>(added);
Assert.AreEqual(0, ((AddStateResult)added).State.Layer);
}
[Test]
public void AddTransition_ExitTimeOutOfRange_Throws()
{
var path = CreateController("BadExitTime");
AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Idle");
AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Run");
Assert.Throws<System.ArgumentException>(
() => AnimatorControllerCommands.AddAnimatorTransition(
PathRef(path), null, "Idle", "Run", conditions: null, hasExitTime: true, exitTime: 1.5f));
var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path));
Assert.AreEqual(0, info.Layers[0].Transitions.Count, "an out-of-range exitTime must write nothing");
}
[Test]
public void AddTransition_NegativeDuration_Throws()
{
var path = CreateController("BadDuration");
AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Idle");
AnimatorControllerCommands.AddAnimatorState(PathRef(path), null, "Run");
Assert.Throws<System.ArgumentException>(
() => AnimatorControllerCommands.AddAnimatorTransition(
PathRef(path), null, "Idle", "Run", conditions: null, duration: -1f));
var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path));
Assert.AreEqual(0, info.Layers[0].Transitions.Count, "a negative duration must write nothing");
}
[Test]
public void AddParameter_DryRun_WritesNothing()
{
var path = CreateController("DryParam");
AnimatorControllerCommands.AddAnimatorParameter(PathRef(path), "Speed", "Float", new JValue(0f), dryRun: true);
var info = AnimatorControllerCommands.GetAnimatorController(PathRef(path));
Assert.AreEqual(0, info.Parameters.Count, "dry_run must not write a parameter");
}
[Test]
public void CreateController_ViaClient_Succeeds()
{
using (var server = new PipelineTestServer())
{
var path = Root + "/ViaClient/Made.controller";
var response = server.Execute("create_animator_controller", new { path });
Assert.IsTrue(response.IsSuccess, $"create_animator_controller should succeed: {response.Error}");
Assert.IsNotNull(AssetDatabase.LoadMainAssetAtPath(path), "Controller should exist on disk");
}
}
}
}
@@ -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>
public class TimelineCommandsTests
{
private const string Root = "Assets/__CLI214TimelineTest";
[TearDown]
public void TearDown()
{
ProjectPaths.ResetAuthoringRoot();
if (AssetDatabase.IsValidFolder(Root))
{
AssetDatabase.DeleteAsset(Root);
AssetDatabase.Refresh();
}
}
private static ObjectRef PathRef(string path) => new ObjectRef { Path = path };
[Test]
public void Timeline_NotInstalled_ReturnsPackageNotFound()
{
if (TimelineGuard.IsInstalled())
Assert.Pass("com.unity.timeline is installed; the not-installed path is covered only when it is absent.");
var result = TimelineCommands.CreateTimeline(Root + "/T.playable");
Assert.IsInstanceOf<ErrorResult>(result, "Timeline commands must not throw when the package is absent");
Assert.AreEqual("package_not_found", ((ErrorResult)result).Code);
}
[Test]
public void Timeline_CreateTrackClipGet_Flow()
{
if (!TimelineGuard.IsInstalled())
Assert.Pass("com.unity.timeline is not installed; the Timeline authoring flow is validated only when the package is present.");
// create_timeline
var timelinePath = Root + "/Cutscene.playable";
var created = TimelineCommands.CreateTimeline(timelinePath, frameRate: 30f);
Assert.IsInstanceOf<AuthoringResult>(created, "create_timeline should return an AuthoringResult when installed");
// add an Animation track
var trackResult = TimelineCommands.AddTimelineTrack(PathRef(timelinePath), "Animation", name: "Anim");
Assert.IsInstanceOf<AddTimelineTrackResult>(trackResult);
// a clip asset for the Animation track
var clipPath = Root + "/Move.anim";
AnimationClipCommands.CreateAnimationClip(clipPath);
// add_timeline_clip (start 0, duration 2)
var clipResult = TimelineCommands.AddTimelineClip(
PathRef(timelinePath), track: "Anim", start: 0f, duration: 2f, asset: PathRef(clipPath));
Assert.IsInstanceOf<AddTimelineClipResult>(clipResult);
// get_timeline => one Animation track with one 2s clip
var info = (TimelineInfo)TimelineCommands.GetTimeline(PathRef(timelinePath));
Assert.AreEqual(1, info.Tracks.Count, "expected exactly one track");
Assert.AreEqual("Animation", info.Tracks[0].TrackType);
Assert.AreEqual(1, info.Tracks[0].Clips.Count, "expected exactly one clip");
Assert.AreEqual(2d, info.Tracks[0].Clips[0].Duration, 0.001d);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 72d8c657533f4b8b96feb3af780d619a
@@ -0,0 +1,259 @@
using System.Linq;
using System.Threading.Tasks;
using NUnit.Framework;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor;
using Unity.Pipeline.Models;
using Newtonsoft.Json.Linq;
using UnityEngine.TestTools;
using System.Text.RegularExpressions;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Tests for HTTP API endpoints that CLI tools will consume.
/// These test the complete server API surface for remote command execution.
/// </summary>
public class ApiEndpointsTests
{
private EditorPipelineServer m_Server;
private Unity.Pipeline.Tests.Runtime.PipelineClient m_PipelineClient;
[SetUp]
public void SetUp()
{
// Setup command discovery for tests
CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
// Start an ISOLATED test server (ports 7850-7899, writes no descriptor) for endpoint
// testing, so we never bind the live server's port (7800) or clobber its descriptor.
m_Server = new TestEditorPipelineServer();
m_Server.Start();
m_PipelineClient = new Unity.Pipeline.Tests.Runtime.PipelineClient(m_Server);
}
[TearDown]
public void TearDown()
{
m_PipelineClient?.Dispose();
m_Server?.Stop();
}
[Test]
public async Task ApiCommands_GetEndpoint_ReturnsCommandList()
{
// Act - Call /api/commands endpoint using unified Pipeline client
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands");
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
// Assert - Response structure
Assert.IsTrue(httpResponse.IsSuccessStatusCode,
$"Commands endpoint should return success, got: {httpResponse.StatusCode}");
Assert.AreEqual("application/json", httpResponse.Content.Headers.ContentType.MediaType,
"Commands endpoint should return JSON content type");
// Assert - JSON parsing
var responseJson = JObject.Parse(jsonContent);
Assert.IsNotNull(responseJson, "Should be able to parse commands JSON");
// Verify response structure
Assert.IsNotNull(responseJson["commands"], "Response should have commands array");
Assert.IsNotNull(responseJson["count"], "Response should have count field");
Assert.IsNotNull(responseJson["server"], "Response should have server info");
// Verify commands array contains discovered commands
var commands = responseJson["commands"] as JArray;
Assert.Greater(commands.Count, 0, "Should have at least one discovered command");
// Verify a specific test command is included
var testCommand = commands.Cast<JObject>()
.FirstOrDefault(cmd => cmd["name"]?.ToString() == "log_editor");
Assert.IsNotNull(testCommand, "Should include log_editor test command");
Assert.AreEqual("Log a message to Unity Editor console", testCommand["description"]?.ToString());
}
[Test]
public async Task ApiCommands_OnEditorServer_ExcludesRuntimeOnlyCommands()
{
// Act - List commands from the Editor server
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands");
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
var responseJson = JObject.Parse(jsonContent);
var commandNames = (responseJson["commands"] as JArray)
.Cast<JObject>()
.Select(cmd => cmd["name"]?.ToString())
.ToList();
// Assert - editor commands are listed, runtime-only commands are hidden
Assert.Contains("editor_status", commandNames, "Editor command should be listed");
CollectionAssert.DoesNotContain(commandNames, "runtime_status", "Runtime-only eval should be hidden on the Editor server");
CollectionAssert.DoesNotContain(commandNames, "set_target_framerate", "Runtime-only reload_file_override should be hidden on the Editor server");
}
[Test]
public async Task ApiCommands_CommandStructure_ContainsRequiredFields()
{
// Act - Call /api/commands endpoint using unified Pipeline client
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands");
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
var responseJson = JObject.Parse(jsonContent);
// Assert - Command structure validation
var commands = responseJson["commands"] as JArray;
var firstCommand = commands[0] as JObject;
// Required command fields
Assert.IsNotNull(firstCommand["name"], "Command should have name field");
Assert.IsNotNull(firstCommand["description"], "Command should have description field");
Assert.IsNotNull(firstCommand["parameters"], "Command should have parameters array");
Assert.IsNotNull(firstCommand["schema"], "Command should have JSON schema");
Assert.IsNotNull(firstCommand["mainThreadRequired"], "Command should have mainThreadRequired field");
// Verify schema is valid JSON
var schema = firstCommand["schema"]?.ToString();
var schemaJson = JObject.Parse(schema);
Assert.AreEqual(firstCommand["name"]?.ToString(), schemaJson["title"]?.ToString(),
"Schema title should match command name");
}
[Test]
public async Task ApiStatus_GetBasicStatus_ReturnsServerInfo()
{
// Act - Call basic /api/status endpoint using unified Pipeline client
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/status");
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
// Assert - Response structure
Assert.IsTrue(httpResponse.IsSuccessStatusCode,
$"Basic status endpoint should return success, got: {httpResponse.StatusCode}");
Assert.AreEqual("application/json", httpResponse.Content.Headers.ContentType.MediaType,
"Basic status endpoint should return JSON content type");
// Assert - JSON structure (basic server info only, no Editor APIs)
var statusJson = JObject.Parse(jsonContent);
Assert.IsNotNull(statusJson["status"], "Should have status field");
// Verify basic values
Assert.AreEqual("ready", statusJson["status"]?.ToString());
}
[Test]
public async Task ApiEditorStatus_GetDetailedStatus_ReturnsEditorInfo()
{
// Act - Call rich /api/editor_status endpoint using unified Pipeline client
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/editor_status");
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
// Assert - Response structure
Assert.IsTrue(httpResponse.IsSuccessStatusCode,
$"Editor status endpoint should return success, got: {httpResponse.StatusCode}. Response: {jsonContent}");
Assert.AreEqual("application/json", httpResponse.Content.Headers.ContentType.MediaType,
"Editor status endpoint should return JSON content type");
// Assert - Rich Editor status structure
var statusJson = JObject.Parse(jsonContent);
Assert.IsNotNull(statusJson["status"], "Should have overall status");
Assert.IsNotNull(statusJson["compiling"], "Should have compiling state");
Assert.IsNotNull(statusJson["domainReloadInProgress"], "Should have domain reload state");
Assert.IsNotNull(statusJson["playMode"], "Should have play mode state");
Assert.IsNotNull(statusJson["unityVersion"], "Should have Unity version");
// Verify Editor-specific data is present
Assert.Contains(statusJson["status"]?.ToString(), new[] { "ready", "compiling", "playing", "reloading" });
Assert.Contains(statusJson["playMode"]?.ToString(), new[] { "stopped", "playing", "paused" });
Assert.IsInstanceOf<bool>(statusJson["compiling"]?.ToObject<bool>());
}
[Test]
public async Task ApiExec_PostCommand_ExecutesSuccessfully()
{
// Arrange
var commandRequest = new CommandExecutionRequest("log_editor");
commandRequest.Parameters["message"] = "Test message from CLI";
// Act - Execute command via /api/exec endpoint using unified Pipeline client
var response = await m_PipelineClient.PostJsonAsync("/api/exec", commandRequest);
var responseContent = response.RawResponse;
// Assert - Response structure
Assert.IsTrue(response.IsSuccess,
$"Exec endpoint should return success, got: {response.StatusCode}. Response: {responseContent}");
// Assert - JSON parsing and structure
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
var responseJson = response.JsonResponse;
Assert.IsNotNull(responseJson["success"], "Response should have success field");
Assert.IsNotNull(responseJson["command"], "Response should have command field");
Assert.IsNotNull(responseJson["executedAt"], "Response should have executedAt timestamp");
// Assert - Successful execution
Assert.IsTrue(responseJson["success"].ToObject<bool>(), "Command should execute successfully");
Assert.AreEqual("log_editor", responseJson["command"]?.ToString());
}
[Test]
public async Task ApiExec_InvalidCommand_ReturnsError()
{
// Arrange
var invalidRequest = new CommandExecutionRequest("nonexistent_command");
// Act - Execute invalid command via /api/exec endpoint using unified Pipeline client
var response = await m_PipelineClient.PostJsonAsync("/api/exec", invalidRequest);
var responseContent = response.RawResponse;
LogAssert.Expect(new Regex("^ExecuteCommandByName: No command named"));
// Assert - Should return error
Assert.IsFalse(response.IsSuccess, "Should return error for invalid command");
Assert.IsTrue(response.HasValidJson, "Error response should have valid JSON");
var responseJson = response.JsonResponse;
Assert.IsNotNull(responseJson["error"], "Error response should have error field");
Assert.IsNotNull(responseJson["message"], "Error response should have message field");
}
[Test]
public async Task ApiExec_MissingRequiredParameter_ReturnsValidationError()
{
// Arrange - Try to execute log_editor without required message parameter
var invalidRequest = new CommandExecutionRequest("log_editor");
// Intentionally not setting the 'message' parameter to test validation
// Act - Execute command with missing parameter via /api/exec endpoint using unified Pipeline client
var response = await m_PipelineClient.PostJsonAsync("/api/exec", invalidRequest);
var responseContent = response.RawResponse;
LogAssert.Expect("ExecuteCommandByName: Parameter validation failed: Required parameter 'message' is missing or empty");
// Assert - Should return validation error
Assert.IsFalse(response.IsSuccess, "Should return error for missing required parameter");
Assert.IsTrue(response.HasValidJson, "Error response should have valid JSON");
var responseJson = response.JsonResponse;
Assert.IsNotNull(responseJson["error"], "Should have error field");
Assert.That(responseJson["errorDetails"]?.ToString(),
Contains.Substring("message").IgnoreCase,
"Error should mention missing message parameter");
}
[Test]
public async Task ApiExec_OversizedBody_ReturnsPayloadTooLarge()
{
// Arrange - a request whose body exceeds the 1 MiB cap (Content-Length will advertise it).
var oversized = new CommandExecutionRequest("log_editor");
oversized.Parameters["message"] = new string('a', (1 * 1024 * 1024) + 1024);
// Act
var response = await m_PipelineClient.PostJsonAsync("/api/exec", oversized);
// Assert - rejected with 413 before the command ever runs.
Assert.AreEqual(413, response.StatusCode,
$"Oversized body should be rejected with 413. Response: {response.RawResponse}");
Assert.IsTrue(response.HasValidJson, "413 response should have valid JSON");
Assert.That(response.JsonResponse["error"]?.ToString(),
Contains.Substring("Payload Too Large"),
"413 response should identify the payload-too-large error");
}
}
}
@@ -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>
public class CLI191SampleAsset : ScriptableObject
{
public int Value;
}
/// <summary>
/// Tests for the CLI-191 asset lifecycle commands, exercised both directly (static method) and via
/// the isolated <see cref="PipelineTestServer"/>. Covers the acceptance flow (create folder tree →
/// create ScriptableObject → find by type → move → delete), the sandbox guard (out-of-project
/// writes rejected), and the destructive guard (delete without confirm rejected).
/// </summary>
public class AssetCommandsTests
{
private const string Root = "Assets/__CLI191Test";
[TearDown]
public void TearDown()
{
ProjectPaths.ResetAuthoringRoot();
if (AssetDatabase.IsValidFolder(Root))
{
AssetDatabase.DeleteAsset(Root);
AssetDatabase.Refresh();
}
}
private static ObjectRef PathRef(string path) => new ObjectRef { Path = path };
#region Direct
[Test]
public void CreateAsset_ScriptableObject_CreatesAssetAndReturnsIdentity()
{
var path = Root + "/Data/Sample.asset";
var result = AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName);
Assert.AreEqual(path, result.AssetPath);
Assert.IsNotEmpty(result.Guid, "Created asset should have a GUID");
Assert.IsNotNull(AssetDatabase.LoadAssetAtPath<CLI191SampleAsset>(path), "Asset should exist on disk");
}
[Test]
public void CreateAsset_OverwriteWithoutConfirm_Throws()
{
var path = Root + "/Data/Dup.asset";
AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName);
Assert.Throws<System.ArgumentException>(
() => AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName),
"Overwriting an existing asset without confirm should be rejected");
// With confirm it succeeds.
var result = AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName, confirm: true);
Assert.AreEqual(path, result.AssetPath);
}
[Test]
public void CreateAsset_OutOfProject_Throws()
{
Assert.Throws<System.ArgumentException>(
() => AssetCommands.CreateAsset("../Outside.asset", typeof(CLI191SampleAsset).FullName));
Assert.Throws<System.ArgumentException>(
() => AssetCommands.CreateAsset("Assets/../Outside.asset", typeof(CLI191SampleAsset).FullName));
}
[Test]
public void FindAssets_ByType_FindsCreatedAsset()
{
var path = Root + "/Find/Target.asset";
AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName);
var found = AssetCommands.FindAssets(type: nameof(CLI191SampleAsset), searchIn: Root);
Assert.GreaterOrEqual(found.Count, 1, "Should find the created asset by type");
CollectionAssert.Contains(
System.Linq.Enumerable.Select(found.Assets, a => a.AssetPath),
path);
}
[Test]
public void FindAssets_NoFilter_Throws()
{
Assert.Throws<System.ArgumentException>(() => AssetCommands.FindAssets());
}
[Test]
public void MoveAsset_RelocatesAndPreservesGuid()
{
var src = Root + "/Move/Src.asset";
AssetCommands.CreateAsset(src, typeof(CLI191SampleAsset).FullName);
var srcGuid = AssetDatabase.AssetPathToGUID(src);
var dest = Root + "/Move/Dest/Moved.asset";
var result = AssetCommands.MoveAsset(PathRef(src), dest);
Assert.AreEqual(dest, result.AssetPath);
Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(src), "Source should no longer exist");
Assert.AreEqual(srcGuid, AssetDatabase.AssetPathToGUID(dest), "GUID should be preserved across a move");
}
[Test]
public void CopyAsset_CreatesDistinctAsset()
{
var src = Root + "/Copy/Src.asset";
AssetCommands.CreateAsset(src, typeof(CLI191SampleAsset).FullName);
var dest = Root + "/Copy/Copy.asset";
var result = AssetCommands.CopyAsset(PathRef(src), dest);
Assert.AreEqual(dest, result.AssetPath);
Assert.IsNotNull(AssetDatabase.LoadMainAssetAtPath(src), "Source should still exist after copy");
Assert.AreNotEqual(AssetDatabase.AssetPathToGUID(src), AssetDatabase.AssetPathToGUID(dest),
"A copy should get a fresh GUID");
}
[Test]
public void RenameAsset_RenamesInPlace()
{
var src = Root + "/Rename/Old.asset";
AssetCommands.CreateAsset(src, typeof(CLI191SampleAsset).FullName);
var result = AssetCommands.RenameAsset(PathRef(src), "New");
Assert.AreEqual(Root + "/Rename/New.asset", result.AssetPath);
Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(src));
}
[Test]
public void DeleteAsset_WithoutConfirm_Throws()
{
var path = Root + "/Delete/Doomed.asset";
AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName);
Assert.Throws<System.ArgumentException>(() => AssetCommands.DeleteAsset(PathRef(path)),
"delete_asset without confirm should be rejected");
Assert.IsNotNull(AssetDatabase.LoadMainAssetAtPath(path), "Asset should survive a refused delete");
}
[Test]
public void DeleteAsset_WithConfirm_Deletes()
{
var path = Root + "/Delete/Doomed2.asset";
AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName);
var result = AssetCommands.DeleteAsset(PathRef(path), confirm: true);
Assert.AreEqual(path, result.AssetPath);
Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(path), "Asset should be gone after a confirmed delete");
}
[Test]
public void AcceptanceFlow_CreateFindMoveDelete()
{
// create folder tree
FolderCommands.CreateFolder(Root + "/Flow/Configs");
Assert.IsTrue(AssetDatabase.IsValidFolder(Root + "/Flow/Configs"));
// create ScriptableObject asset
var path = Root + "/Flow/Configs/Cfg.asset";
AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName);
// find by type
var found = AssetCommands.FindAssets(type: nameof(CLI191SampleAsset), searchIn: Root + "/Flow");
Assert.GreaterOrEqual(found.Count, 1);
// move it
var moved = AssetCommands.MoveAsset(PathRef(path), Root + "/Flow/Moved.asset");
Assert.AreEqual(Root + "/Flow/Moved.asset", moved.AssetPath);
// delete it (with confirm)
AssetCommands.DeleteAsset(PathRef(moved.AssetPath), confirm: true);
Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(moved.AssetPath));
}
#endregion
#region ViaClient
[Test]
public void CreateAsset_ViaClient_Succeeds()
{
using (var server = new PipelineTestServer())
{
var path = Root + "/ViaClient/Made.asset";
var response = server.Execute("create_asset", new { path, type = typeof(CLI191SampleAsset).FullName });
Assert.IsTrue(response.IsSuccess, $"create_asset should succeed: {response.Error}");
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field");
Assert.IsNotNull(AssetDatabase.LoadAssetAtPath<CLI191SampleAsset>(path), "Asset should exist");
}
}
[Test]
public void DeleteAsset_ViaClient_WithoutConfirm_Fails()
{
var path = Root + "/ViaClient/ToDelete.asset";
AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName);
using (var server = new PipelineTestServer())
{
// The server logs the refusal as a Unity [Error]; expect it so the test does not fail on
// the unexpected log. Matches the message thrown by DeleteAsset ("Refusing to delete ...").
LogAssert.Expect(LogType.Error, new Regex("Refusing to delete"));
var response = server.Execute("delete_asset", new { asset = new { path } });
Assert.IsFalse(response.IsSuccess, "delete_asset without confirm should fail over the wire");
Assert.IsNotNull(AssetDatabase.LoadMainAssetAtPath(path), "Asset should survive a refused delete");
}
}
[Test]
public void FindAssets_ViaClient_ReturnsResults()
{
var path = Root + "/ViaClient/Findable.asset";
AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName);
using (var server = new PipelineTestServer())
{
var response = server.Execute("find_assets", new { type = nameof(CLI191SampleAsset), search_in = Root });
Assert.IsTrue(response.IsSuccess, $"find_assets should succeed: {response.Error}");
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field");
}
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d4736a7747a847edb2355c95559ce0b0
@@ -0,0 +1,103 @@
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Editor.Commands.Assets;
using Unity.Pipeline.Models;
using UnityEditor;
namespace Unity.Pipeline.Tests.Editor.Assets
{
/// <summary>
/// Tests for set_import_settings (CLI-191). Uses the importer's <c>userData</c> property — present
/// on every AssetImporter — so the test is independent of any specific importer kind. Verifies the
/// applied/unknown bookkeeping and that the setting actually persists on the importer.
/// </summary>
public class AssetImportCommandsTests
{
private const string Root = "Assets/__CLI191ImportTest";
[TearDown]
public void TearDown()
{
ProjectPaths.ResetAuthoringRoot();
if (AssetDatabase.IsValidFolder(Root))
{
AssetDatabase.DeleteAsset(Root);
AssetDatabase.Refresh();
}
}
private static ObjectRef PathRef(string path) => new ObjectRef { Path = path };
private static string CreateSampleAsset()
{
var path = Root + "/Imported.asset";
AssetCommands.CreateAsset(path, typeof(CLI191SampleAsset).FullName);
return path;
}
#region Direct
[Test]
public void SetImportSettings_AppliesKnownMember()
{
var path = CreateSampleAsset();
var settings = new JObject { ["userData"] = "cli191" };
var result = AssetImportCommands.SetImportSettings(PathRef(path), settings);
CollectionAssert.Contains(result.Applied, "userData");
CollectionAssert.IsEmpty(result.Unknown);
Assert.AreEqual("cli191", AssetImporter.GetAtPath(path).userData, "Setting should persist on the importer");
}
[Test]
public void SetImportSettings_UnknownOnly_Throws()
{
var path = CreateSampleAsset();
var settings = new JObject { ["definitelyNotAMember"] = 42 };
Assert.Throws<System.ArgumentException>(
() => AssetImportCommands.SetImportSettings(PathRef(path), settings),
"When no supplied setting exists on the importer, the command should fail");
}
[Test]
public void SetImportSettings_DryRun_DoesNotPersist()
{
var path = CreateSampleAsset();
var settings = new JObject { ["userData"] = "dryrun" };
var result = AssetImportCommands.SetImportSettings(PathRef(path), settings, dryRun: true);
// dry_run reports the member as applicable but never mutates the importer.
CollectionAssert.Contains(result.Applied, "userData");
Assert.AreNotEqual("dryrun", AssetImporter.GetAtPath(path).userData, "dry_run should not persist the change");
}
#endregion
#region ViaClient
[Test]
public void SetImportSettings_ViaClient_Succeeds()
{
var path = CreateSampleAsset();
using (var server = new PipelineTestServer())
{
var response = server.Execute("set_import_settings", new
{
asset = new { path },
settings = new { userData = "viaclient" }
});
Assert.IsTrue(response.IsSuccess, $"set_import_settings should succeed: {response.Error}");
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
Assert.AreEqual("viaclient", AssetImporter.GetAtPath(path).userData);
}
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 121fa6671cf449ce863c389aa9b7e18c
@@ -0,0 +1,432 @@
using System.IO;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Editor.Commands.Assets;
using Unity.Pipeline.Models;
using UnityEditor;
using UnityEngine;
namespace Unity.Pipeline.Tests.Editor.Assets
{
/// <summary>
/// Tests for the CLI-212 per-type import settings + platform overrides:
/// <c>get_import_settings</c> and the platform-aware extension of <c>set_import_settings</c>.
///
/// Real importers are exercised by writing tiny generated assets, so the actual Unity read/write
/// code paths (including per-platform overrides) are covered end to end: a 4x4 PNG (TextureImporter),
/// a minimal OBJ (ModelImporter), and a tiny 8-bit PCM WAV (AudioImporter). The audio fixture drives
/// a full per-platform override round-trip (<c>ApplyAudioPlatform</c> +
/// <c>ReadAudioPlatformOverride</c>): set override, read it back, then clear it.
/// </summary>
public class ImportSettingsCommandTests
{
private const string Root = "Assets/__CLI212ImportTest";
[TearDown]
public void TearDown()
{
ProjectPaths.ResetAuthoringRoot();
if (AssetDatabase.IsValidFolder(Root))
{
AssetDatabase.DeleteAsset(Root);
AssetDatabase.Refresh();
}
}
private static ObjectRef PathRef(string path) => new ObjectRef { Path = path };
// ------------------------------------------------------------------ asset fixtures
/// <summary>Write a tiny 4x4 PNG so Unity imports it with a TextureImporter.</summary>
private static string CreateTexture(string name = "Tex.png")
{
EnsureRoot();
var tex = new Texture2D(4, 4, TextureFormat.RGBA32, false);
var pixels = new Color32[16];
for (var i = 0; i < pixels.Length; i++)
pixels[i] = new Color32(255, 128, 64, 255);
tex.SetPixels32(pixels);
tex.Apply();
var bytes = tex.EncodeToPNG();
Object.DestroyImmediate(tex);
var projectPath = Root + "/" + name;
File.WriteAllBytes(ToAbsolute(projectPath), bytes);
AssetDatabase.ImportAsset(projectPath, ImportAssetOptions.ForceSynchronousImport);
return projectPath;
}
/// <summary>Write a minimal single-triangle OBJ so Unity imports it with a ModelImporter.</summary>
private static string CreateModel(string name = "Tri.obj")
{
EnsureRoot();
const string obj =
"o Tri\n" +
"v 0 0 0\n" +
"v 1 0 0\n" +
"v 0 1 0\n" +
"vn 0 0 1\n" +
"f 1//1 2//1 3//1\n";
var projectPath = Root + "/" + name;
File.WriteAllText(ToAbsolute(projectPath), obj);
AssetDatabase.ImportAsset(projectPath, ImportAssetOptions.ForceSynchronousImport);
return projectPath;
}
/// <summary>
/// Write a tiny mono 8-bit PCM WAV (a handful of samples) so Unity imports it with an
/// AudioImporter — enough to exercise the real per-platform override read/write paths.
/// </summary>
private static string CreateAudio(string name = "Beep.wav")
{
EnsureRoot();
const int sampleRate = 8000;
const int sampleCount = 64;
var pcm = new byte[sampleCount];
for (var i = 0; i < pcm.Length; i++)
pcm[i] = (byte)(128 + (i % 2 == 0 ? 32 : -32)); // crude square wave around the 8-bit midpoint
using (var stream = new MemoryStream())
using (var w = new BinaryWriter(stream))
{
const short channels = 1;
const short bitsPerSample = 8;
var byteRate = sampleRate * channels * bitsPerSample / 8;
var blockAlign = (short)(channels * bitsPerSample / 8);
// RIFF header
w.Write(new[] { 'R', 'I', 'F', 'F' });
w.Write(36 + pcm.Length);
w.Write(new[] { 'W', 'A', 'V', 'E' });
// fmt chunk
w.Write(new[] { 'f', 'm', 't', ' ' });
w.Write(16); // PCM fmt chunk size
w.Write((short)1); // audio format = PCM
w.Write(channels);
w.Write(sampleRate);
w.Write(byteRate);
w.Write(blockAlign);
w.Write(bitsPerSample);
// data chunk
w.Write(new[] { 'd', 'a', 't', 'a' });
w.Write(pcm.Length);
w.Write(pcm);
w.Flush();
var projectPath = Root + "/" + name;
File.WriteAllBytes(ToAbsolute(projectPath), stream.ToArray());
AssetDatabase.ImportAsset(projectPath, ImportAssetOptions.ForceSynchronousImport);
return projectPath;
}
}
private static void EnsureRoot()
{
if (!AssetDatabase.IsValidFolder(Root))
AssetDatabase.CreateFolder("Assets", "__CLI212ImportTest");
}
private static string ToAbsolute(string projectRelative) =>
$"{ProjectPaths.ProjectRoot.Replace('\\', '/')}/{projectRelative}";
// ------------------------------------------------------------------ texture: read
[Test]
public void GetImportSettings_Texture_ReturnsStructuredDefaultsAndOverrideBlock()
{
var path = CreateTexture();
var result = AssetImportCommands.GetImportSettings(PathRef(path), platform: "Android");
Assert.AreEqual("TextureImporter", result.ImporterType);
Assert.AreEqual("Android", result.Platform);
Assert.IsNotNull(result.Settings);
Assert.IsTrue(result.Settings.ContainsKey("textureType"));
Assert.IsTrue(result.Settings.ContainsKey("isReadable"));
Assert.IsTrue(result.Settings.ContainsKey("mipmapEnabled"));
Assert.IsTrue(result.Settings.ContainsKey("wrapMode"));
Assert.IsNotNull(result.PlatformOverride, "Texture should expose a platform override block");
Assert.IsTrue(result.PlatformOverride.ContainsKey("overridden"));
}
// ------------------------------------------------------------------ texture: default round-trip
[Test]
public void SetImportSettings_TextureDefault_AppliesAndReadBackReflects()
{
var path = CreateTexture();
var settings = new JObject
{
["isReadable"] = true,
["textureType"] = "NormalMap"
};
var set = AssetImportCommands.SetImportSettings(PathRef(path), settings);
CollectionAssert.Contains(set.Applied, "isReadable");
CollectionAssert.Contains(set.Applied, "textureType");
CollectionAssert.IsEmpty(set.Unknown);
var read = AssetImportCommands.GetImportSettings(PathRef(path));
Assert.AreEqual(true, read.Settings["isReadable"]);
Assert.AreEqual("NormalMap", read.Settings["textureType"]);
}
// ------------------------------------------------------------------ texture: platform override set + clear
[Test]
public void SetImportSettings_TextureAndroidOverride_SetsThenClears()
{
var path = CreateTexture();
// Default platform maxTextureSize before the override (should be unchanged afterwards).
var defaultBefore = AssetImportCommands.GetImportSettings(PathRef(path));
var defaultMaxBefore = defaultBefore.Settings["maxTextureSize"];
var set = AssetImportCommands.SetImportSettings(
PathRef(path),
new JObject { ["maxTextureSize"] = 1024, ["format"] = "ASTC_6x6", ["overridden"] = true },
platform: "Android");
CollectionAssert.Contains(set.Applied, "maxTextureSize");
CollectionAssert.Contains(set.Applied, "format");
var afterSet = AssetImportCommands.GetImportSettings(PathRef(path), platform: "Android");
Assert.AreEqual(true, afterSet.PlatformOverride["overridden"]);
Assert.AreEqual(1024, afterSet.PlatformOverride["maxTextureSize"]);
Assert.AreEqual("ASTC_6x6", afterSet.PlatformOverride["format"]);
// Default platform unchanged by the Android override.
var defaultAfter = AssetImportCommands.GetImportSettings(PathRef(path));
Assert.AreEqual(defaultMaxBefore, defaultAfter.Settings["maxTextureSize"],
"Default platform settings must be unchanged by a platform override");
// Clear the override.
AssetImportCommands.SetImportSettings(
PathRef(path),
new JObject { ["overridden"] = false },
platform: "Android");
var afterClear = AssetImportCommands.GetImportSettings(PathRef(path), platform: "Android");
Assert.AreEqual(false, afterClear.PlatformOverride["overridden"], "Override should be cleared");
}
// ------------------------------------------------------------------ audio: read
[Test]
public void GetImportSettings_Audio_ReturnsStructuredDefaultsAndOverrideBlock()
{
var path = CreateAudio();
var result = AssetImportCommands.GetImportSettings(PathRef(path), platform: "Android");
Assert.AreEqual("AudioImporter", result.ImporterType);
Assert.AreEqual("Android", result.Platform);
Assert.IsNotNull(result.Settings);
Assert.IsTrue(result.Settings.ContainsKey("loadType"));
Assert.IsTrue(result.Settings.ContainsKey("compressionFormat"));
Assert.IsTrue(result.Settings.ContainsKey("quality"));
Assert.IsNotNull(result.PlatformOverride, "Audio should expose a platform override block");
Assert.IsTrue(result.PlatformOverride.ContainsKey("overridden"));
// No override has been written yet.
Assert.AreEqual(false, result.PlatformOverride["overridden"]);
}
// ------------------------------------------------------------------ audio: platform override set + clear
[Test]
public void SetImportSettings_AudioAndroidOverride_SetsThenClears()
{
var path = CreateAudio();
// Default-platform compressionFormat before the override (must be unchanged afterwards).
var defaultBefore = AssetImportCommands.GetImportSettings(PathRef(path));
var defaultCompressionBefore = defaultBefore.Settings["compressionFormat"];
var set = AssetImportCommands.SetImportSettings(
PathRef(path),
new JObject { ["compressionFormat"] = "Vorbis", ["loadType"] = "CompressedInMemory", ["overridden"] = true },
platform: "Android");
CollectionAssert.Contains(set.Applied, "compressionFormat");
CollectionAssert.Contains(set.Applied, "loadType");
CollectionAssert.Contains(set.Applied, "overridden");
CollectionAssert.IsEmpty(set.Unknown);
var afterSet = AssetImportCommands.GetImportSettings(PathRef(path), platform: "Android");
Assert.AreEqual(true, afterSet.PlatformOverride["overridden"], "Override should be present after a platform write");
Assert.AreEqual("Vorbis", afterSet.PlatformOverride["compressionFormat"]);
Assert.AreEqual("CompressedInMemory", afterSet.PlatformOverride["loadType"]);
// Default platform unchanged by the Android override.
var defaultAfter = AssetImportCommands.GetImportSettings(PathRef(path));
Assert.AreEqual(defaultCompressionBefore, defaultAfter.Settings["compressionFormat"],
"Default platform settings must be unchanged by a platform override");
// Clear the override.
AssetImportCommands.SetImportSettings(
PathRef(path),
new JObject { ["overridden"] = false },
platform: "Android");
var afterClear = AssetImportCommands.GetImportSettings(PathRef(path), platform: "Android");
Assert.AreEqual(false, afterClear.PlatformOverride["overridden"], "Override should be cleared");
}
// ------------------------------------------------------------------ model: read + default write
[Test]
public void GetImportSettings_Model_ReturnsNullPlatformOverride()
{
var path = CreateModel();
var result = AssetImportCommands.GetImportSettings(PathRef(path));
Assert.AreEqual("ModelImporter", result.ImporterType);
Assert.IsTrue(result.Settings.ContainsKey("meshCompression"));
Assert.IsTrue(result.Settings.ContainsKey("animationType"));
Assert.IsTrue(result.Settings.ContainsKey("materialImportMode"));
Assert.IsNull(result.PlatformOverride, "ModelImporter has no per-platform overrides");
}
[Test]
public void SetImportSettings_ModelDefault_AppliesAndReadBack()
{
var path = CreateModel();
var set = AssetImportCommands.SetImportSettings(
PathRef(path),
new JObject { ["meshCompression"] = "High", ["importNormals"] = "Calculate" });
CollectionAssert.Contains(set.Applied, "meshCompression");
CollectionAssert.Contains(set.Applied, "importNormals");
var read = AssetImportCommands.GetImportSettings(PathRef(path));
Assert.AreEqual("High", read.Settings["meshCompression"]);
Assert.AreEqual("Calculate", read.Settings["importNormals"]);
}
[Test]
public void SetImportSettings_ModelWithPlatform_ReturnsNoPlatformOverrides_AndWritesNothing()
{
var path = CreateModel();
var before = AssetImportCommands.GetImportSettings(PathRef(path));
var meshBefore = before.Settings["meshCompression"];
var ex = Assert.Throws<System.ArgumentException>(() =>
AssetImportCommands.SetImportSettings(
PathRef(path),
new JObject { ["meshCompression"] = "High" },
platform: "iOS"));
StringAssert.Contains("no_platform_overrides", ex.Message);
var after = AssetImportCommands.GetImportSettings(PathRef(path));
Assert.AreEqual(meshBefore, after.Settings["meshCompression"], "A rejected platform write must change nothing");
}
// ------------------------------------------------------------------ dry_run
[Test]
public void SetImportSettings_DryRun_ReportsButDoesNotChange()
{
var path = CreateTexture();
var before = AssetImportCommands.GetImportSettings(PathRef(path));
var readableBefore = (bool)before.Settings["isReadable"];
var result = AssetImportCommands.SetImportSettings(
PathRef(path),
new JObject { ["isReadable"] = !readableBefore },
dryRun: true);
CollectionAssert.Contains(result.Applied, "isReadable");
var after = AssetImportCommands.GetImportSettings(PathRef(path));
Assert.AreEqual(readableBefore, (bool)after.Settings["isReadable"], "dry_run must not change settings");
}
// ------------------------------------------------------------------ unknown key handling
[Test]
public void SetImportSettings_UnknownKey_GoesToUnknown()
{
var path = CreateTexture();
var result = AssetImportCommands.SetImportSettings(
PathRef(path),
new JObject { ["isReadable"] = true, ["definitelyNotAMember"] = 42 });
CollectionAssert.Contains(result.Applied, "isReadable");
CollectionAssert.Contains(result.Unknown, "definitelyNotAMember");
}
[Test]
public void SetImportSettings_AllUnknown_Throws()
{
var path = CreateTexture();
Assert.Throws<System.ArgumentException>(() =>
AssetImportCommands.SetImportSettings(
PathRef(path),
new JObject { ["definitelyNotAMember"] = 42 }));
}
// ------------------------------------------------------------------ platform validation
[Test]
public void SetImportSettings_UnknownPlatform_Throws()
{
var path = CreateTexture();
var ex = Assert.Throws<System.ArgumentException>(() =>
AssetImportCommands.SetImportSettings(
PathRef(path),
new JObject { ["maxTextureSize"] = 512 },
platform: "PlayStation"));
StringAssert.Contains("unknown_platform", ex.Message);
}
[Test]
public void GetImportSettings_UnknownPlatform_Throws()
{
var path = CreateTexture();
Assert.Throws<System.ArgumentException>(() =>
AssetImportCommands.GetImportSettings(PathRef(path), platform: "PlayStation"));
}
// ------------------------------------------------------------------ sandbox guard
[Test]
public void GetImportSettings_OutsideAuthoringRoot_Throws()
{
var path = CreateTexture();
// Confine the authoring root to a sub-folder that does NOT contain the asset, so the asset's
// resolved path falls outside the sandbox.
ProjectPaths.AuthoringRoot = Root + "/Nested";
Assert.Throws<System.ArgumentException>(() =>
AssetImportCommands.GetImportSettings(PathRef(path)));
}
// ------------------------------------------------------------------ via client (wire) smoke test
[Test]
public void GetImportSettings_ViaClient_Succeeds()
{
var path = CreateTexture();
using (var server = new PipelineTestServer())
{
var response = server.Execute("get_import_settings", new
{
asset = new { path },
platform = "Android"
});
Assert.IsTrue(response.IsSuccess, $"get_import_settings should succeed: {response.Error}");
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 48fae49d74ce4b61941f9c5ff541034f
@@ -0,0 +1,182 @@
using System.Text.RegularExpressions;
using NUnit.Framework;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Editor.Commands.Assets;
using UnityEditor;
using UnityEngine;
using UnityEngine.TestTools;
namespace Unity.Pipeline.Tests.Editor.Assets
{
/// <summary>
/// Tests for create_asset's Material support (CLI-221), exercised directly and via the isolated
/// <see cref="PipelineTestServer"/>. Verifies that a .mat is created with a real (non-null) shader,
/// that an unknown shader name fails clearly (no null Material / null-ref), and that the created
/// Material is loadable and assignable to a Renderer's material slot.
/// </summary>
public class MaterialAssetCommandsTests
{
private const string Root = "Assets/__CLI221_222Test";
// A shader that is guaranteed to exist on whatever pipeline this project runs (built-in or URP).
private string m_KnownShader;
[SetUp]
public void SetUp()
{
ProjectPaths.ResetAuthoringRoot();
m_KnownShader = PickKnownShader();
}
[TearDown]
public void TearDown()
{
ProjectPaths.ResetAuthoringRoot();
if (AssetDatabase.IsValidFolder(Root))
{
AssetDatabase.DeleteAsset(Root);
AssetDatabase.Refresh();
}
}
/// <summary>Pick a shader name that resolves on this project's active pipeline.</summary>
private static string PickKnownShader()
{
if (Shader.Find("Standard") != null)
return "Standard";
if (Shader.Find("Universal Render Pipeline/Lit") != null)
return "Universal Render Pipeline/Lit";
// Sprites/Default ships with every pipeline as a hard fallback.
return "Sprites/Default";
}
#region Direct
[Test]
public void CreateAsset_Material_WithShader_CreatesValidMaterial()
{
var path = Root + "/Mats/Bar.mat";
var result = AssetCommands.CreateAsset(path, "Material", shader: m_KnownShader);
Assert.AreEqual(path, result.AssetPath);
var loaded = AssetDatabase.LoadAssetAtPath<Material>(path);
Assert.IsNotNull(loaded, "Created .mat should load as a Material");
Assert.IsNotNull(loaded.shader, "Material's shader must not be null");
Assert.AreEqual(Shader.Find(m_KnownShader), loaded.shader, "Material should use the requested shader");
}
[Test]
public void CreateAsset_Material_DefaultShader_IsNonNull()
{
// No explicit shader: should fall back to the active pipeline's lit shader (or Standard).
var path = Root + "/Mats/Default.mat";
AssetCommands.CreateAsset(path, "Material");
var loaded = AssetDatabase.LoadAssetAtPath<Material>(path);
Assert.IsNotNull(loaded, "Created .mat should load as a Material");
Assert.IsNotNull(loaded.shader, "Default-shader Material must have a non-null shader");
}
[Test]
public void CreateAsset_Material_InvalidShader_ThrowsClearError()
{
var path = Root + "/Mats/Broken.mat";
var ex = Assert.Throws<System.ArgumentException>(
() => AssetCommands.CreateAsset(path, "Material", shader: "No/Such/Shader__CLI221"),
"An unknown shader name should fail with a clear ArgumentException");
StringAssert.Contains("not found", ex.Message);
Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(path), "No asset should be written on a shader failure");
}
[Test]
public void CreateAsset_Material_DryRun_InvalidShader_ThrowsAndWritesNothing()
{
// dry_run is documented as "validate inputs", so an unknown shader must fail fast even in
// dry_run (rather than reporting a would-be success) and still write nothing.
var path = Root + "/Mats/DryRunBroken.mat";
var ex = Assert.Throws<System.ArgumentException>(
() => AssetCommands.CreateAsset(path, "Material", shader: "No/Such/Shader__CLI221", dryRun: true),
"dry_run should resolve/validate the shader and fail on an unknown one");
StringAssert.Contains("not found", ex.Message);
Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(path), "dry_run must never write an asset");
}
[Test]
public void CreateAsset_Material_DryRun_ValidShader_WritesNothing()
{
// A valid dry_run resolves the shader (no throw) but writes no asset.
var path = Root + "/Mats/DryRunOk.mat";
var result = AssetCommands.CreateAsset(path, "Material", shader: m_KnownShader, dryRun: true);
Assert.AreEqual(path, result.AssetPath);
Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(path), "dry_run must never write an asset");
}
[Test]
public void CreateAsset_Material_AssignableToRendererSlot()
{
var path = Root + "/Mats/Assignable.mat";
AssetCommands.CreateAsset(path, "Material", shader: m_KnownShader);
var mat = AssetDatabase.LoadAssetAtPath<Material>(path);
var go = new GameObject("__CLI221_Renderer");
try
{
var renderer = go.AddComponent<MeshRenderer>();
renderer.sharedMaterial = mat;
Assert.AreEqual(mat, renderer.sharedMaterial, "Created Material should be assignable to a Renderer slot");
}
finally
{
Object.DestroyImmediate(go);
}
}
#endregion
#region ViaClient
[Test]
public void CreateAsset_Material_ViaClient_Succeeds()
{
using (var server = new PipelineTestServer())
{
var path = Root + "/ViaClient/Wall.mat";
var response = server.Execute("create_asset", new { path, type = "Material", shader = m_KnownShader });
Assert.IsTrue(response.IsSuccess, $"create_asset (Material) should succeed: {response.Error}");
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field");
var loaded = AssetDatabase.LoadAssetAtPath<Material>(path);
Assert.IsNotNull(loaded, "Material should exist on disk");
Assert.IsNotNull(loaded.shader, "Material's shader must not be null");
}
}
[Test]
public void CreateAsset_Material_ViaClient_InvalidShader_Fails()
{
using (var server = new PipelineTestServer())
{
// The server logs the failure as a Unity [Error]; expect it so the test does not fail on
// the unexpected log. Matches the message thrown by ResolveShader ("Shader '...' not found.").
LogAssert.Expect(LogType.Error, new Regex("not found"));
var path = Root + "/ViaClient/Broken.mat";
var response = server.Execute("create_asset", new { path, type = "Material", shader = "No/Such/Shader__CLI221" });
Assert.IsFalse(response.IsSuccess, "An unknown shader should fail over the wire");
Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(path), "No asset should be written on a shader failure");
}
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7d514449899cc0f439d287ab64433a22
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,160 @@
using NUnit.Framework;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Editor.Commands.Assets;
using UnityEditor;
using UnityEngine;
#if UNITY_6000_0_OR_NEWER
using PipelinePhysicsMaterial = UnityEngine.PhysicsMaterial;
#else
using PipelinePhysicsMaterial = UnityEngine.PhysicMaterial;
#endif
namespace Unity.Pipeline.Tests.Editor.Assets
{
/// <summary>
/// Tests for create_asset's PhysicsMaterial support (CLI-222), exercised directly and via the
/// isolated <see cref="PipelineTestServer"/>. Verifies that BOTH the Unity 6 type name
/// "PhysicsMaterial" and the legacy "PhysicMaterial" resolve to <see cref="PhysicsMaterial"/>, that
/// the created asset carries the documented defaults (bounciness 0, frictions 0.6), is discoverable
/// via find_assets, and is assignable to a Collider's material slot.
///
/// NOTE: Unity 6 renamed the class to PhysicsMaterial but the on-disk asset extension is still
/// ".physicMaterial" (AssetDatabase.CreateAsset rejects ".physicsMaterial"). create_asset normalizes
/// a ".physicsMaterial" request to ".physicMaterial", so the created path is asserted via the
/// command result rather than the requested path.
/// </summary>
public class PhysicsMaterialAssetCommandsTests
{
private const string Root = "Assets/__CLI221_222Test";
[SetUp]
public void SetUp()
{
ProjectPaths.ResetAuthoringRoot();
}
[TearDown]
public void TearDown()
{
ProjectPaths.ResetAuthoringRoot();
if (AssetDatabase.IsValidFolder(Root))
{
AssetDatabase.DeleteAsset(Root);
AssetDatabase.Refresh();
}
}
private static void AssertDefaults(PipelinePhysicsMaterial pm)
{
Assert.IsNotNull(pm, "Created physics material should load as a PhysicsMaterial");
Assert.AreEqual(0f, pm.bounciness, 1e-4f, "bounciness default should be 0");
Assert.AreEqual(0.6f, pm.dynamicFriction, 1e-4f, "dynamicFriction default should be 0.6");
Assert.AreEqual(0.6f, pm.staticFriction, 1e-4f, "staticFriction default should be 0.6");
}
#region Direct
[Test]
public void CreateAsset_PhysicsMaterial_CreatesWithDefaults()
{
var result = AssetCommands.CreateAsset(Root + "/Phys/Ball.physicMaterial", "PhysicsMaterial");
StringAssert.EndsWith(".physicMaterial", result.AssetPath);
AssertDefaults(AssetDatabase.LoadAssetAtPath<PipelinePhysicsMaterial>(result.AssetPath));
}
[Test]
public void CreateAsset_PhysicsMaterial_NormalizesModernExtension()
{
// CLI-222: an agent may pass the modern ".physicsMaterial" extension; it must succeed,
// normalized to the ".physicMaterial" file Unity actually writes.
var result = AssetCommands.CreateAsset(Root + "/Phys/Modern.physicsMaterial", "PhysicsMaterial");
StringAssert.EndsWith(".physicMaterial", result.AssetPath);
AssertDefaults(AssetDatabase.LoadAssetAtPath<PipelinePhysicsMaterial>(result.AssetPath));
}
[Test]
public void CreateAsset_PhysicsMaterial_LegacyTypeName_ResolvesToSameType()
{
// CLI-222: the legacy "PhysicMaterial" spelling must resolve to UnityEngine.PhysicsMaterial.
var result = AssetCommands.CreateAsset(Root + "/Phys/Legacy.physicMaterial", "PhysicMaterial");
var loaded = AssetDatabase.LoadMainAssetAtPath(result.AssetPath);
Assert.IsInstanceOf<PipelinePhysicsMaterial>(loaded, "Legacy 'PhysicMaterial' type name should resolve to PhysicsMaterial");
AssertDefaults(loaded as PipelinePhysicsMaterial);
}
[Test]
public void CreateAsset_PhysicsMaterial_FoundByFindAssets()
{
var result = AssetCommands.CreateAsset(Root + "/Phys/Findable.physicMaterial", "PhysicsMaterial");
var found = AssetCommands.FindAssets(type: "PhysicsMaterial", searchIn: Root);
Assert.GreaterOrEqual(found.Count, 1, "Should find the created PhysicsMaterial by type");
CollectionAssert.Contains(
System.Linq.Enumerable.Select(found.Assets, a => a.AssetPath),
result.AssetPath);
}
[Test]
public void CreateAsset_PhysicsMaterial_AssignableToColliderSlot()
{
var result = AssetCommands.CreateAsset(Root + "/Phys/Assignable.physicMaterial", "PhysicsMaterial");
var pm = AssetDatabase.LoadAssetAtPath<PipelinePhysicsMaterial>(result.AssetPath);
var go = new GameObject("__CLI222_Collider");
try
{
var collider = go.AddComponent<BoxCollider>();
collider.sharedMaterial = pm;
Assert.AreEqual(pm, collider.sharedMaterial, "Created PhysicsMaterial should be assignable to a Collider slot");
}
finally
{
Object.DestroyImmediate(go);
}
}
#endregion
#region ViaClient
[Test]
public void CreateAsset_PhysicsMaterial_ViaClient_Succeeds()
{
using (var server = new PipelineTestServer())
{
var response = server.Execute("create_asset",
new { path = Root + "/ViaClient/Ball.physicMaterial", type = "PhysicsMaterial" });
Assert.IsTrue(response.IsSuccess, $"create_asset (PhysicsMaterial) should succeed: {response.Error}");
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field");
var created = (string)response.JsonResponse["result"]["assetPath"];
AssertDefaults(AssetDatabase.LoadAssetAtPath<PipelinePhysicsMaterial>(created));
}
}
[Test]
public void CreateAsset_PhysicsMaterial_ViaClient_LegacyName_Succeeds()
{
using (var server = new PipelineTestServer())
{
var response = server.Execute("create_asset",
new { path = Root + "/ViaClient/Legacy.physicMaterial", type = "PhysicMaterial" });
Assert.IsTrue(response.IsSuccess, $"create_asset (legacy PhysicMaterial) should succeed: {response.Error}");
var created = (string)response.JsonResponse["result"]["assetPath"];
Assert.IsInstanceOf<PipelinePhysicsMaterial>(AssetDatabase.LoadMainAssetAtPath(created),
"Legacy 'PhysicMaterial' should resolve to PhysicsMaterial over the wire");
}
}
#endregion
}
}
@@ -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>
public class TextFileCommandsTests
{
private const string Root = "Assets/__CLI191TextTest";
[TearDown]
public void TearDown()
{
ProjectPaths.ResetAuthoringRoot();
if (AssetDatabase.IsValidFolder(Root))
{
AssetDatabase.DeleteAsset(Root);
AssetDatabase.Refresh();
}
}
#region Direct
[Test]
public void WriteThenRead_RoundTrips()
{
var path = Root + "/Notes/hello.txt";
const string content = "hello cli-191";
var write = TextFileCommands.WriteTextFile(path, content);
Assert.AreEqual(path, write.AssetPath);
var read = TextFileCommands.ReadTextFile(path);
Assert.AreEqual(content, read.Contents);
Assert.AreEqual(System.Text.Encoding.UTF8.GetByteCount(content), read.Bytes);
}
[Test]
public void WriteTextFile_OverwriteWithoutConfirm_Throws()
{
var path = Root + "/Notes/dup.txt";
TextFileCommands.WriteTextFile(path, "first");
Assert.Throws<System.ArgumentException>(
() => TextFileCommands.WriteTextFile(path, "second"),
"Overwriting an existing file without confirm should be rejected");
var ok = TextFileCommands.WriteTextFile(path, "second", confirm: true);
Assert.AreEqual(path, ok.AssetPath);
Assert.AreEqual("second", TextFileCommands.ReadTextFile(path).Contents);
}
[Test]
public void WriteTextFile_OutOfProject_Throws()
{
Assert.Throws<System.ArgumentException>(() => TextFileCommands.WriteTextFile("../escape.txt", "x"));
Assert.Throws<System.ArgumentException>(() => TextFileCommands.WriteTextFile("Assets/../escape.txt", "x"));
}
[Test]
public void ReadTextFile_Missing_Throws()
{
Assert.Throws<System.ArgumentException>(() => TextFileCommands.ReadTextFile(Root + "/does-not-exist.txt"));
}
#endregion
#region ViaClient
[Test]
public void WriteTextFile_ViaClient_Succeeds()
{
using (var server = new PipelineTestServer())
{
var path = Root + "/ViaClient/wire.txt";
var response = server.Execute("write_text_file", new { path, contents = "over the wire" });
Assert.IsTrue(response.IsSuccess, $"write_text_file should succeed: {response.Error}");
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
Assert.AreEqual("over the wire", TextFileCommands.ReadTextFile(path).Contents);
}
}
[Test]
public void ReadTextFile_ViaClient_Succeeds()
{
var path = Root + "/ViaClient/readable.txt";
TextFileCommands.WriteTextFile(path, "readable content");
using (var server = new PipelineTestServer())
{
var response = server.Execute("read_text_file", new { path });
Assert.IsTrue(response.IsSuccess, $"read_text_file should succeed: {response.Error}");
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field");
}
}
#endregion
}
}
@@ -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>
public class FolderCommandsTests
{
private const string Root = "Assets/__CLI190Test";
[SetUp]
public void SetUp()
{
// Start from the default authoring root so a leftover EditorPref (from another test or an
// interrupted run) can't shift where bare paths resolve mid-suite.
ProjectPaths.ResetAuthoringRoot();
}
[TearDown]
public void TearDown()
{
ProjectPaths.ResetAuthoringRoot();
if (AssetDatabase.IsValidFolder(Root))
{
AssetDatabase.DeleteAsset(Root);
AssetDatabase.Refresh();
}
}
#region Direct
[Test]
public void CreateFolder_ExplicitAssetsPath_CreatesFoldersAndReturnsIdentity()
{
var result = FolderCommands.CreateFolder(Root + "/Sub/Leaf");
Assert.IsTrue(AssetDatabase.IsValidFolder(Root + "/Sub/Leaf"), "Nested folders should exist");
Assert.AreEqual(Root + "/Sub/Leaf", result.AssetPath);
Assert.IsNotEmpty(result.Guid, "Created folder should have a GUID");
}
[Test]
public void CreateFolder_BarePath_ResolvesUnderAssets()
{
// No "Assets/" prefix — should resolve under the default authoring root (Assets/).
var result = FolderCommands.CreateFolder("__CLI190Test/Bare");
Assert.AreEqual("Assets/__CLI190Test/Bare", result.AssetPath);
Assert.IsTrue(AssetDatabase.IsValidFolder("Assets/__CLI190Test/Bare"));
}
[Test]
public void CreateFolder_Traversal_Throws()
{
Assert.Throws<System.ArgumentException>(() => FolderCommands.CreateFolder("../Outside"));
Assert.Throws<System.ArgumentException>(() => FolderCommands.CreateFolder("Assets/../Outside"));
}
#endregion
#region ViaClient
[Test]
public void CreateFolder_ViaClient_Succeeds()
{
using (var server = new PipelineTestServer())
{
var response = server.Execute("create_folder", new { path = Root + "/ViaClient" });
Assert.IsTrue(response.IsSuccess, $"create_folder should succeed: {response.Error}");
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field");
Assert.IsTrue(AssetDatabase.IsValidFolder(Root + "/ViaClient"), "Folder should be created");
}
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4653539f2cf4a1b4699bd2c7a8d35188
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,223 @@
using NUnit.Framework;
using Newtonsoft.Json;
using Unity.Pipeline;
using Unity.Pipeline.Models;
using UnityEngine;
using Object = UnityEngine.Object;
namespace Unity.Pipeline.Tests.Editor.Authoring
{
/// <summary>
/// Tests for <see cref="ObjectRefConverter"/>: a single canonical string handle coerces into the
/// matching ObjectRef field (mirroring ObjectResolver/ToString priority), structured objects still
/// deserialize fully, and serialization still emits a JSON object. Plus one end-to-end ViaClient
/// test proving a string target flows through the real /api/exec dispatch path.
/// </summary>
public class ObjectRefConverterTests
{
private static ObjectRef Parse(string handle) =>
JsonConvert.DeserializeObject<ObjectRef>("\"" + handle + "\"");
#region String coercion (one field per canonical form)
[Test]
public void String_HierarchyPath_LeadingSlash()
{
var r = Parse("/Player");
Assert.AreEqual("/Player", r.HierarchyPath);
Assert.IsNull(r.GlobalId); Assert.IsNull(r.Path); Assert.IsNull(r.Guid);
Assert.IsNull(r.InstanceId); Assert.IsNull(r.FileId);
}
[Test]
public void String_HierarchyPath_Nested()
{
Assert.AreEqual("/Level/Floor", Parse("/Level/Floor").HierarchyPath);
}
[Test]
public void String_GlobalId()
{
var r = Parse("GlobalObjectId_V1-2-abc123def4560000abc123def4560000-1095335325-0");
Assert.AreEqual("GlobalObjectId_V1-2-abc123def4560000abc123def4560000-1095335325-0", r.GlobalId);
Assert.IsNull(r.HierarchyPath);
}
[Test]
public void String_AssetPath()
{
var r = Parse("Assets/Prefabs/Enemy.prefab");
Assert.AreEqual("Assets/Prefabs/Enemy.prefab", r.Path);
Assert.IsNull(r.HierarchyPath);
}
[Test]
public void String_GuidWithFileId()
{
var r = Parse("guid:a1b2c3d4e5f6:123");
Assert.AreEqual("a1b2c3d4e5f6", r.Guid);
Assert.AreEqual(123L, r.FileId);
}
[Test]
public void String_GuidOnly_Prefixed()
{
var r = Parse("guid:a1b2c3d4e5f6");
Assert.AreEqual("a1b2c3d4e5f6", r.Guid);
Assert.IsNull(r.FileId);
}
[Test]
public void String_InstanceId_Prefixed_Negative()
{
#if !UNITY_6000_4_OR_NEWER
Assert.AreEqual(ObjectId.FromRaw(-3070), Parse("instanceId:-3070").InstanceId);
#else
Assert.Ignore("Negative int instance ids do not exist in the EntityId (ulong) model on 6000.4+.");
#endif
}
[Test]
public void String_PlainNegativeInt_IsInstanceId()
{
#if !UNITY_6000_4_OR_NEWER
var r = Parse("-3070");
Assert.AreEqual(ObjectId.FromRaw(-3070), r.InstanceId);
Assert.IsNull(r.HierarchyPath);
#else
Assert.Ignore("Negative int instance ids do not exist in the EntityId (ulong) model on 6000.4+.");
#endif
}
[Test]
public void String_PlainPositiveInt_IsInstanceId()
{
Assert.AreEqual(ObjectId.FromRaw(48184), Parse("48184").InstanceId);
}
[Test]
public void String_32Hex_IsGuid()
{
var r = Parse("a1b2c3d4e5f60718293a4b5c6d7e8f90");
Assert.AreEqual("a1b2c3d4e5f60718293a4b5c6d7e8f90", r.Guid);
Assert.IsNull(r.InstanceId);
}
[Test]
public void String_BareName_FallsBackToHierarchyPath()
{
var r = Parse("Enemy_1");
Assert.AreEqual("Enemy_1", r.HierarchyPath);
Assert.IsNull(r.InstanceId); Assert.IsNull(r.Guid);
}
#endregion
#region Authoring-root-relative asset paths (AUTHAPI-9)
[Test]
public void String_RelativePathWithExtension_IsPath()
{
// No "Assets/" prefix but a file extension: an authoring-root-relative asset path.
var r = Parse("Materials/FloorA.mat");
Assert.AreEqual("Materials/FloorA.mat", r.Path);
Assert.IsNull(r.HierarchyPath);
}
[Test]
public void String_BareFileWithExtension_IsPath()
{
var r = Parse("Enemy.prefab");
Assert.AreEqual("Enemy.prefab", r.Path);
Assert.IsNull(r.HierarchyPath);
}
[Test]
public void String_RelativeNoExtension_StaysHierarchyPath()
{
// No extension and no leading slash: still a scene hierarchy path, not an asset candidate.
var r = Parse("Root/Child");
Assert.AreEqual("Root/Child", r.HierarchyPath);
Assert.IsNull(r.Path);
}
[Test]
public void String_LeadingSlashWithExtension_StaysHierarchyPath()
{
// A leading slash marks a canonical hierarchy path even when a name looks dotted.
var r = Parse("/Root/Cube.001");
Assert.AreEqual("/Root/Cube.001", r.HierarchyPath);
Assert.IsNull(r.Path);
}
#endregion
#region Object form, write, null
[Test]
public void Object_Structured_DeserializesAllFields()
{
var json = "{\"globalId\":\"G\",\"path\":\"Assets/A\",\"guid\":\"a1\",\"fileId\":42,\"instanceId\":7,\"hierarchyPath\":\"/H\"}";
var r = JsonConvert.DeserializeObject<ObjectRef>(json);
Assert.AreEqual("G", r.GlobalId);
Assert.AreEqual("Assets/A", r.Path);
Assert.AreEqual("a1", r.Guid);
Assert.AreEqual(42L, r.FileId);
Assert.AreEqual(ObjectId.FromRaw(7), r.InstanceId);
Assert.AreEqual("/H", r.HierarchyPath);
}
[Test]
public void WriteJson_EmitsObject_NotString_AndRoundTrips()
{
var json = JsonConvert.SerializeObject(new ObjectRef { HierarchyPath = "/Player" });
StringAssert.StartsWith("{", json, "ObjectRef must serialize as a JSON object, not a bare string");
StringAssert.Contains("\"hierarchyPath\":\"/Player\"", json);
var back = JsonConvert.DeserializeObject<ObjectRef>(json);
Assert.AreEqual("/Player", back.HierarchyPath);
}
[Test]
public void Null_DeserializesToNull()
{
Assert.IsNull(JsonConvert.DeserializeObject<ObjectRef>("null"));
}
[Test]
public void Number_BareInteger_IsInstanceId()
{
Assert.AreEqual(ObjectId.FromRaw(48184), JsonConvert.DeserializeObject<ObjectRef>("48184").InstanceId);
}
#endregion
#region End-to-end via the real dispatch path
[Test]
public void SetTransform_StringTarget_ResolvesAndApplies()
{
var go = new GameObject("CLI_ObjRefDispatch");
try
{
using (var server = new PipelineTestServer())
{
var resp = server.Execute("set_transform",
new { target = "/CLI_ObjRefDispatch", position = new[] { 1f, 2f, 3f } });
Assert.IsTrue(resp.IsSuccess,
$"set_transform with a string target should succeed (string->ObjectRef coercion): {resp.Error}");
}
Assert.AreEqual(new Vector3(1f, 2f, 3f), go.transform.localPosition,
"The string handle should have resolved to the GameObject and applied the transform");
}
finally
{
Object.DestroyImmediate(go);
}
}
#endregion
}
}
@@ -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>
public class ObjectResolverTests
{
private const string AssetFolder = "Assets/AUTHAPI9_Res";
private GameObject m_SceneObject;
[TearDown]
public void TearDown()
{
if (m_SceneObject != null)
Object.DestroyImmediate(m_SceneObject);
m_SceneObject = null;
if (AssetDatabase.IsValidFolder(AssetFolder))
{
AssetDatabase.DeleteAsset(AssetFolder);
AssetDatabase.Refresh();
}
}
private static string CreateMaterialAsset(string name)
{
if (!AssetDatabase.IsValidFolder(AssetFolder))
AssetDatabase.CreateFolder("Assets", "AUTHAPI9_Res");
var shader = Shader.Find("Standard")
?? Shader.Find("Universal Render Pipeline/Lit")
?? Shader.Find("Sprites/Default");
var mat = new Material(shader);
var path = $"{AssetFolder}/{name}.mat";
AssetDatabase.CreateAsset(mat, path);
AssetDatabase.SaveAssets();
return path;
}
[Test]
public void Describe_SceneObject_ProducesInstanceIdAndHierarchyPath()
{
m_SceneObject = new GameObject("CLI190_Root");
var child = new GameObject("Child");
child.transform.SetParent(m_SceneObject.transform);
var info = ObjectResolver.Describe(child);
Assert.IsNotNull(info);
Assert.AreEqual(PipelineUtils.GetObjectId(child), info.InstanceId);
Assert.AreEqual("/CLI190_Root/Child", info.HierarchyPath);
Assert.AreEqual("GameObject", info.Type);
Assert.IsNull(info.AssetPath, "A scene object should not report an asset path");
}
[Test]
public void Resolve_ByInstanceId_ReturnsSameObject()
{
m_SceneObject = new GameObject("CLI190_ById");
var handle = new ObjectRef { InstanceId = PipelineUtils.GetObjectId(m_SceneObject) };
Assert.IsTrue(ObjectResolver.TryResolve(handle, out var obj, out var error), error);
Assert.AreSame(m_SceneObject, obj);
}
[Test]
public void Resolve_ByHierarchyPath_ReturnsSameObject()
{
m_SceneObject = new GameObject("CLI190_ByPath");
var handle = new ObjectRef { HierarchyPath = "/CLI190_ByPath" };
Assert.IsTrue(ObjectResolver.TryResolve(handle, out var obj, out var error), error);
Assert.AreSame(m_SceneObject, obj);
}
[Test]
public void Resolve_EmptyHandle_FailsWithError()
{
Assert.IsFalse(ObjectResolver.TryResolve(new ObjectRef(), out _, out var error));
Assert.IsNotEmpty(error);
}
[Test]
public void Resolve_UnknownGuid_FailsWithError()
{
var handle = new ObjectRef { Guid = "00000000000000000000000000000000" };
Assert.IsFalse(ObjectResolver.TryResolve(handle, out _, out var error));
Assert.IsNotEmpty(error);
}
[Test]
public void Resolve_ByRelativeAssetPath_NormalizesUnderAuthoringRoot()
{
// A path without the "Assets/" prefix (AUTHAPI-9) resolves under the authoring root.
var full = CreateMaterialAsset("Floor");
var expected = AssetDatabase.LoadMainAssetAtPath(full);
var handle = new ObjectRef { Path = "AUTHAPI9_Res/Floor.mat" };
Assert.IsTrue(ObjectResolver.TryResolve(handle, out var obj, out var error), error);
Assert.AreSame(expected, obj);
}
[Test]
public void Resolve_ExplicitAssetsPath_StillResolves()
{
var full = CreateMaterialAsset("Wall");
var handle = new ObjectRef { Path = full };
Assert.IsTrue(ObjectResolver.TryResolve(handle, out var obj, out var error), error);
Assert.AreSame(AssetDatabase.LoadMainAssetAtPath(full), obj);
}
[Test]
public void Resolve_DottedGameObjectName_FallsBackToHierarchy()
{
// A dotted name (e.g. "Cube.001") is routed to Path by the string converter but is really a
// scene object; the Path branch falls back to a hierarchy lookup.
m_SceneObject = new GameObject("Cube.001");
var handle = new ObjectRef { Path = "Cube.001" };
Assert.IsTrue(ObjectResolver.TryResolve(handle, out var obj, out var error), error);
Assert.AreSame(m_SceneObject, obj);
}
[Test]
public void Resolve_UnresolvableRelativePath_ErrorListsEveryStrategy()
{
var handle = new ObjectRef { Path = "AUTHAPI9_Res/Missing.mat" };
Assert.IsFalse(ObjectResolver.TryResolve(handle, out _, out var error));
StringAssert.Contains("no asset at", error);
StringAssert.Contains("no GameObject at hierarchy path", error);
}
}
}
@@ -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>
public class ProjectPathsTests
{
[SetUp]
public void SetUp()
{
// Reset to the default root before each test so a leftover EditorPref can't leak in.
ProjectPaths.ResetAuthoringRoot();
}
[TearDown]
public void TearDown()
{
ProjectPaths.ResetAuthoringRoot();
}
[Test]
public void DefaultRoot_IsAssets()
{
ProjectPaths.ResetAuthoringRoot();
Assert.AreEqual("Assets", ProjectPaths.AuthoringRoot);
}
[Test]
public void Resolve_BarePath_PrependsAssets()
{
var resolved = ProjectPaths.Resolve("Gameplay/Enemies", out var error);
Assert.IsNull(error, error);
Assert.AreEqual("Assets/Gameplay/Enemies", resolved);
}
[Test]
public void Resolve_ExplicitAssetsPath_Unchanged()
{
var resolved = ProjectPaths.Resolve("Assets/Gameplay", out var error);
Assert.IsNull(error, error);
Assert.AreEqual("Assets/Gameplay", resolved);
}
[Test]
public void Resolve_Traversal_Fails()
{
Assert.IsNull(ProjectPaths.Resolve("../Outside", out var error));
Assert.IsNotEmpty(error);
}
[Test]
public void Resolve_EmptyPath_Fails()
{
Assert.IsNull(ProjectPaths.Resolve("", out var error));
Assert.IsNotEmpty(error);
}
[Test]
public void ConfigurableRoot_ConfinesBareAndRejectsOutside()
{
ProjectPaths.AuthoringRoot = "Assets/__CLI190Root";
// Bare path resolves under the configured root.
var inside = ProjectPaths.Resolve("Foo/Bar", out var insideError);
Assert.IsNull(insideError, insideError);
Assert.AreEqual("Assets/__CLI190Root/Foo/Bar", inside);
// Explicit path outside the configured root is rejected (confinement).
Assert.IsNull(ProjectPaths.Resolve("Assets/Other", out var outsideError));
Assert.IsNotEmpty(outsideError);
}
[Test]
public void SetAuthoringRoot_OutsideAssets_Throws()
{
Assert.Throws<System.ArgumentException>(() => ProjectPaths.AuthoringRoot = "Outside");
Assert.Throws<System.ArgumentException>(() => ProjectPaths.AuthoringRoot = "Assets/../Escape");
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9af4698d059fd9d43b72c6e1dad56424
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5e5fd89f0ad042538cfef1bd87763a27
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,199 @@
using System.Linq;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor;
using Unity.Pipeline.Editor.Commands.Baking;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine.SceneManagement;
namespace Unity.Pipeline.Tests.Editor.Baking
{
/// <summary>
/// Tests for the CLI-215 lighting bake commands. These cover the parts that are deterministic
/// without waiting on a real (minutes-long) lightmap bake: command discovery/schema, the
/// settings get/set round-trip, the destructive clear guard, the idle status, the no_scene guard,
/// and dry_run being a no-op. The full bake → completed acceptance flow is exercised live against a
/// running Editor (it is too slow/platform-dependent for the unit suite).
/// </summary>
public class LightingBakeCommandsTests
{
[SetUp]
public void SetUp() => CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
[TearDown]
public void TearDown()
{
// Leave the editor with a fresh empty (untitled) scene so other tests are unaffected.
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
}
[Test]
public void LightingCommands_AreDiscovered_WithExpectedSchema()
{
var commands = CommandRegistry.DiscoverCommands().ToList();
var bake = commands.FirstOrDefault(c => c.Name == "bake_lighting");
Assert.IsNotNull(bake, "Should discover bake_lighting");
CollectionAssert.AreEquivalent(new[] { "confirm", "dry_run" },
bake.Parameters.Select(p => p.Name).ToList());
var status = commands.FirstOrDefault(c => c.Name == "lighting_bake_status");
Assert.IsNotNull(status, "Should discover lighting_bake_status");
Assert.IsFalse(status.MainThreadRequired,
"lighting_bake_status must be off-main-thread so it answers while a bake holds the main thread");
Assert.AreEqual(0, status.Parameters.Count, "lighting_bake_status takes no parameters");
Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "cancel_lighting_bake"));
Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "clear_baked_lighting"));
Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "get_lighting_settings"));
Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "set_lighting_settings"));
}
[Test]
public void GetLightingSettings_ReturnsCoreFields()
{
var settings = LightingBakeCommands.GetLightingSettings();
if (!settings.Available)
{
// No LightingSettings are exposed when no scene is open (e.g. batchmode test
// projects start with an untitled empty scene). Skip rather than fail.
Assert.Pass("No active LightingSettings — skipping (no scene open in test project).");
return;
}
Assert.IsNotNull(settings.Lightmapper, "lightmapper should be reported");
// directionalMode must be one of the two spec values, never a raw flags-enum string.
Assert.That(settings.DirectionalMode, Is.EqualTo("NonDirectional").Or.EqualTo("Directional"),
"directionalMode must be 'NonDirectional' or 'Directional'");
// lightmapCompression must be the LightmapCompression enum name, not a bool.
var validCompressions = new[] { "None", "LowQuality", "NormalQuality", "HighQuality" };
CollectionAssert.Contains(validCompressions, settings.LightmapCompression,
"lightmapCompression must be one of the LightmapCompression enum names");
}
[Test]
public void SetLightingSettings_DirectionalMode_RoundTrips()
{
var before = LightingBakeCommands.GetLightingSettings();
if (!before.Available)
{
Assert.Pass("No active LightingSettings — skipping directionalMode round-trip test.");
return;
}
var target = before.DirectionalMode == "NonDirectional" ? "Directional" : "NonDirectional";
try
{
var result = JObject.FromObject(LightingBakeCommands.SetLightingSettings(
new JObject { ["directionalMode"] = target }));
CollectionAssert.Contains(result["applied"].Select(t => t.Value<string>()).ToList(),
"directionalMode", "directionalMode should be in applied[]");
var after = LightingBakeCommands.GetLightingSettings();
Assert.That(after.DirectionalMode, Is.EqualTo("NonDirectional").Or.EqualTo("Directional"),
"directionalMode must remain a spec-valid string after set");
}
finally
{
LightingBakeCommands.SetLightingSettings(new JObject { ["directionalMode"] = before.DirectionalMode });
}
}
[Test]
public void SetLightingSettings_RoundTrips_BouncesAndResolution()
{
var before = LightingBakeCommands.GetLightingSettings();
var newBounces = before.Bounces == 3 ? 2 : 3;
var newResolution = before.LightmapResolution >= 40f ? 20f : 50f;
try
{
var settings = new JObject
{
["bounces"] = newBounces,
["lightmapResolution"] = newResolution
};
LightingBakeCommands.SetLightingSettings(settings);
var after = LightingBakeCommands.GetLightingSettings();
Assert.AreEqual(newBounces, after.Bounces, "bounces should round-trip");
Assert.AreEqual(newResolution, after.LightmapResolution, 0.001f, "lightmapResolution should round-trip");
}
finally
{
// Restore the prior values so the suite leaves no residue in the host project's
// lighting settings (the active settings may be a shared scene-default or asset).
LightingBakeCommands.SetLightingSettings(new JObject
{
["bounces"] = before.Bounces,
["lightmapResolution"] = before.LightmapResolution
});
}
}
[Test]
public void SetLightingSettings_ReportsUnknownKeys()
{
var settings = new JObject
{
["bounces"] = 2,
["totallyNotARealKey"] = 5
};
var result = JObject.FromObject(LightingBakeCommands.SetLightingSettings(settings));
var applied = result["applied"].Select(t => t.Value<string>()).ToList();
var unknown = result["unknown"].Select(t => t.Value<string>()).ToList();
CollectionAssert.Contains(applied, "bounces");
CollectionAssert.Contains(unknown, "totallyNotARealKey");
}
[Test]
public void SetLightingSettings_DryRun_DoesNotChangeAnything()
{
var before = LightingBakeCommands.GetLightingSettings();
var changed = before.Bounces == 1 ? 4 : 1;
LightingBakeCommands.SetLightingSettings(new JObject { ["bounces"] = changed }, dryRun: true);
var after = LightingBakeCommands.GetLightingSettings();
Assert.AreEqual(before.Bounces, after.Bounces, "dry_run must not mutate settings");
}
[Test]
public void ClearBakedLighting_WithoutConfirm_Throws()
{
Assert.Throws<System.ArgumentException>(() => LightingBakeCommands.ClearBakedLighting());
}
[Test]
public void ClearBakedLighting_DryRun_DoesNotThrow_AndReportsWouldClear()
{
var result = JObject.FromObject(LightingBakeCommands.ClearBakedLighting(confirm: true, dryRun: true));
Assert.AreEqual("dry_run", result["status"].Value<string>());
Assert.IsTrue(result["wouldClear"].Value<bool>());
}
[Test]
public void BakeLighting_NoOpenSavedScene_ReturnsNoScene()
{
// A fresh untitled scene has an empty path, so it is not a bakeable (saved) scene.
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
Assert.IsTrue(string.IsNullOrEmpty(SceneManager.GetActiveScene().path), "test precondition: untitled scene");
var result = JObject.FromObject(LightingBakeCommands.BakeLighting());
Assert.AreEqual("no_scene", result["code"].Value<string>());
}
[Test]
public void LightingBakeStatus_Idle_WhenNothingTriggered_OrAfterStatusFile()
{
// The status reflects either a clean idle or the result of a prior (cancelled/completed)
// bake from another test; in all cases it must be valid JSON carrying a "status" field.
var json = JObject.Parse(LightingBakeCommands.LightingBakeStatus());
Assert.IsNotNull(json["status"], "lighting_bake_status should always include a 'status' field");
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: bdeb4ada982746e1aa5d72b49157fd3e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,125 @@
using System.Linq;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor;
using Unity.Pipeline.Editor.Commands.Baking;
using UnityEditor.SceneManagement;
namespace Unity.Pipeline.Tests.Editor.Baking
{
/// <summary>
/// Tests for the CLI-215 legacy NavMesh bake commands. Covers discovery/schema, the settings
/// get/set round-trip, the destructive clear guard, the idle status, the no_scene guard, dry_run
/// being a no-op, and the AI-Navigation stub returning package_not_found when the package is
/// absent. The full bake → succeeded flow is exercised live.
/// </summary>
public class NavMeshBakeCommandsTests
{
[SetUp]
public void SetUp() => CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
[TearDown]
public void TearDown()
{
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
}
[Test]
public void NavMeshCommands_AreDiscovered_WithExpectedSchema()
{
var commands = CommandRegistry.DiscoverCommands().ToList();
Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "bake_navmesh"));
Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "cancel_navmesh_bake"));
Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "clear_navmesh"));
Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "get_navmesh_settings"));
Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "set_navmesh_settings"));
var status = commands.FirstOrDefault(c => c.Name == "navmesh_bake_status");
Assert.IsNotNull(status, "Should discover navmesh_bake_status");
Assert.IsFalse(status.MainThreadRequired, "navmesh_bake_status must be off-main-thread");
Assert.AreEqual(0, status.Parameters.Count, "navmesh_bake_status takes no parameters");
}
[Test]
public void GetNavMeshSettings_ReturnsAgentFields()
{
var settings = NavMeshBakeCommands.GetNavMeshSettings();
Assert.IsTrue(settings.Available, "Legacy NavMesh settings should be available");
Assert.Greater(settings.AgentRadius, 0f, "agentRadius should be a sensible positive default");
Assert.Greater(settings.AgentHeight, 0f, "agentHeight should be a sensible positive default");
}
[Test]
public void SetNavMeshSettings_RoundTrips_AgentRadius()
{
var before = NavMeshBakeCommands.GetNavMeshSettings();
var newRadius = before.AgentRadius >= 0.5f ? 0.35f : 0.75f;
NavMeshBakeCommands.SetNavMeshSettings(new JObject { ["agentRadius"] = newRadius });
var after = NavMeshBakeCommands.GetNavMeshSettings();
Assert.AreEqual(newRadius, after.AgentRadius, 0.001f, "agentRadius should round-trip");
// Restore the prior value so the suite leaves no residue in the host project's settings.
NavMeshBakeCommands.SetNavMeshSettings(new JObject { ["agentRadius"] = before.AgentRadius });
}
[Test]
public void SetNavMeshSettings_ReportsUnknownKeys()
{
var result = JObject.FromObject(
NavMeshBakeCommands.SetNavMeshSettings(new JObject { ["agentRadius"] = 0.5f, ["nope"] = 1 }, dryRun: true));
var applied = result["applied"].Select(t => t.Value<string>()).ToList();
var unknown = result["unknown"].Select(t => t.Value<string>()).ToList();
CollectionAssert.Contains(applied, "agentRadius");
CollectionAssert.Contains(unknown, "nope");
}
[Test]
public void SetNavMeshSettings_DryRun_DoesNotChangeAnything()
{
var before = NavMeshBakeCommands.GetNavMeshSettings();
var changed = before.AgentRadius >= 0.5f ? 0.2f : 0.9f;
NavMeshBakeCommands.SetNavMeshSettings(new JObject { ["agentRadius"] = changed }, dryRun: true);
var after = NavMeshBakeCommands.GetNavMeshSettings();
Assert.AreEqual(before.AgentRadius, after.AgentRadius, 0.001f, "dry_run must not mutate settings");
}
[Test]
public void ClearNavMesh_WithoutConfirm_Throws()
{
Assert.Throws<System.ArgumentException>(() => NavMeshBakeCommands.ClearNavMesh());
}
[Test]
public void BakeNavMesh_NoOpenSavedScene_ReturnsNoScene()
{
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
var result = JObject.FromObject(NavMeshBakeCommands.BakeNavMesh());
Assert.AreEqual("no_scene", result["code"].Value<string>());
}
[Test]
public void NavMeshBakeStatus_AlwaysHasStatusField()
{
var json = JObject.Parse(NavMeshBakeCommands.NavMeshBakeStatus());
Assert.IsNotNull(json["status"]);
}
[Test]
public void BakeNavMeshSurfaces_WithoutPackage_ReturnsPackageNotFound()
{
// This repo does not reference com.unity.ai.navigation, so the stub should report the
// package is absent (or, if the package is present in the host project, not_implemented).
var result = JObject.FromObject(NavMeshBakeCommands.BakeNavMeshSurfaces());
var code = result["code"].Value<string>();
Assert.That(code, Is.EqualTo("package_not_found").Or.EqualTo("not_implemented"));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d88fed4842cc4242b9e25237547c5514
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,69 @@
using System.Linq;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor;
using Unity.Pipeline.Editor.Commands.Baking;
using UnityEditor.SceneManagement;
namespace Unity.Pipeline.Tests.Editor.Baking
{
/// <summary>
/// Tests for the CLI-215 occlusion-culling bake commands. Covers discovery/schema, the destructive
/// clear guard, the idle status, the no_scene guard, and dry_run being a no-op (and echoing the
/// effective parameters). The full bake → succeeded flow is exercised live.
/// </summary>
public class OcclusionBakeCommandsTests
{
[SetUp]
public void SetUp() => CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
[TearDown]
public void TearDown()
{
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
}
[Test]
public void OcclusionCommands_AreDiscovered_WithExpectedSchema()
{
var commands = CommandRegistry.DiscoverCommands().ToList();
var bake = commands.FirstOrDefault(c => c.Name == "bake_occlusion_culling");
Assert.IsNotNull(bake, "Should discover bake_occlusion_culling");
CollectionAssert.AreEquivalent(
new[] { "smallest_occluder", "smallest_hole", "backface_threshold", "confirm", "dry_run" },
bake.Parameters.Select(p => p.Name).ToList());
var status = commands.FirstOrDefault(c => c.Name == "occlusion_bake_status");
Assert.IsNotNull(status, "Should discover occlusion_bake_status");
Assert.IsFalse(status.MainThreadRequired, "occlusion_bake_status must be off-main-thread");
Assert.AreEqual(0, status.Parameters.Count, "occlusion_bake_status takes no parameters");
Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "cancel_occlusion_bake"));
Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "clear_occlusion_culling"));
}
[Test]
public void ClearOcclusion_WithoutConfirm_Throws()
{
Assert.Throws<System.ArgumentException>(() => OcclusionBakeCommands.ClearOcclusionCulling());
}
[Test]
public void BakeOcclusion_NoOpenSavedScene_ReturnsNoScene()
{
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
var result = JObject.FromObject(OcclusionBakeCommands.BakeOcclusionCulling());
Assert.AreEqual("no_scene", result["code"].Value<string>());
}
[Test]
public void OcclusionBakeStatus_AlwaysHasStatusField()
{
var json = JObject.Parse(OcclusionBakeCommands.OcclusionBakeStatus());
Assert.IsNotNull(json["status"]);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f65c8110a95f44cbbf3aaf0d9832aa0e
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>
public class BuildConfigCommandsTests
{
[SetUp]
public void SetUp() => CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
[Test]
public void NewCommands_AreDiscovered()
{
var names = CommandRegistry.DiscoverCommands().Select(c => c.Name).ToList();
foreach (var expected in new[]
{
"list_build_targets", "get_build_settings", "set_build_settings",
"list_build_profiles", "switch_build_target", "switch_build_target_status"
})
{
CollectionAssert.Contains(names, expected, $"Should discover the {expected} command");
}
}
[Test]
public void SwitchBuildTarget_RequiresTarget_AndConfirm()
{
var switchCmd = CommandRegistry.DiscoverCommands().First(c => c.Name == "switch_build_target");
var target = switchCmd.Parameters.First(p => p.Name == "target");
Assert.IsTrue(target.Required, "switch_build_target 'target' must be required");
// Missing confirm must be refused without attempting the switch.
var result = JObjectOf(SwitchBuildTargetCommand.SwitchBuildTarget(
EditorUserBuildSettings.activeBuildTarget == UnityEditor.BuildTarget.StandaloneWindows64
? "StandaloneLinux64"
: "StandaloneWindows64",
confirm: false));
Assert.AreEqual("error", (string)result["status"]);
Assert.AreEqual(false, (bool)result["success"]);
}
[Test]
public void ListBuildTargets_IncludesActiveTarget_AsInstalled()
{
var targets = (List<BuildTargetInfo>)BuildConfigCommands.ListBuildTargets();
Assert.IsNotNull(targets);
Assert.Greater(targets.Count, 0, "should enumerate at least one build target");
var active = EditorUserBuildSettings.activeBuildTarget.ToString();
var entry = targets.FirstOrDefault(t => t.Name == active);
Assert.IsNotNull(entry, "the active build target should appear in the list");
Assert.IsTrue(entry.IsInstalled, "the active build target must be installed");
}
[Test]
public void GetBuildSettings_ReportsActiveTargetAndScenes()
{
var settings = (BuildSettingsResult)BuildConfigCommands.GetBuildSettings();
Assert.IsNotNull(settings);
Assert.AreEqual(EditorUserBuildSettings.activeBuildTarget.ToString(), settings.ActiveBuildTarget);
Assert.IsNotNull(settings.Scenes, "scene list should be present (may be empty)");
Assert.IsNotNull(settings.Il2CppCodeGeneration);
}
[Test]
public void SetBuildSettings_DryRun_ReportsPlan_WithoutMutating()
{
var before = EditorUserBuildSettings.development;
var input = new SetBuildSettingsInput { DevelopmentBuild = !before };
var result = (SetBuildSettingsResult)BuildConfigCommands.SetBuildSettings(input, dryRun: true);
Assert.IsTrue(result.Success);
Assert.IsTrue(result.DryRun);
Assert.IsTrue(result.Applied.ContainsKey("developmentBuild"),
"the flipped field should appear in the dry-run 'applied' plan");
Assert.AreEqual(before, EditorUserBuildSettings.development,
"a dry run must not change EditorUserBuildSettings");
}
[Test]
public void SetBuildSettings_NoSettings_Fails()
{
var result = (SetBuildSettingsResult)BuildConfigCommands.SetBuildSettings(null, dryRun: false);
Assert.IsFalse(result.Success);
}
static Newtonsoft.Json.Linq.JObject JObjectOf(object value) =>
Newtonsoft.Json.Linq.JObject.FromObject(value);
}
}
@@ -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,218 @@
using System;
using System.IO;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Editor.Commands.Capture;
using UnityEditor;
using UnityEngine;
using UnityEngine.Rendering;
namespace Unity.Pipeline.Tests.Editor.Capture
{
/// <summary>
/// Tests for the visual-feedback commands (CLI-199), exercised directly and via PipelineClient.
/// Render tests are GPU-gated: under batchmode/headless the graphics device is
/// <see cref="GraphicsDeviceType.Null"/> and the tests self-ignore rather than fail.
/// </summary>
public class CaptureCommandsTests
{
private const string CameraName = "CLI199_Cam";
private const string SaveFolder = "Assets/CLI199_CaptureTests";
// PNG file signature: 0x89 'P' 'N' 'G' 0x0D 0x0A 0x1A 0x0A.
private static readonly byte[] PngSignature = { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
private GameObject m_CameraObject;
[SetUp]
public void SetUp()
{
m_CameraObject = new GameObject(CameraName);
m_CameraObject.AddComponent<Camera>();
}
[TearDown]
public void TearDown()
{
if (m_CameraObject != null)
UnityEngine.Object.DestroyImmediate(m_CameraObject);
if (AssetDatabase.IsValidFolder(SaveFolder))
AssetDatabase.DeleteAsset(SaveFolder);
}
private static string AbsolutePath(string projectRelative) =>
Path.Combine(ProjectPaths.ProjectRoot, projectRelative);
private static bool IsHeadless => SystemInfo.graphicsDeviceType == GraphicsDeviceType.Null;
private static void AssertPngSignature(byte[] bytes)
{
Assert.GreaterOrEqual(bytes.Length, PngSignature.Length, "PNG payload too short to contain a signature");
for (var i = 0; i < PngSignature.Length; i++)
Assert.AreEqual(PngSignature[i], bytes[i], $"PNG signature mismatch at byte {i}");
}
#region Direct
[Test]
public void CaptureGameView_NamedCamera_ReturnsPng()
{
if (IsHeadless)
Assert.Ignore("No GPU in batchmode");
var result = CaptureCommands.CaptureGameView(64, 64, CameraName);
Assert.IsNotNull(result, "Capture should return a result");
Assert.IsNotEmpty(result.Base64, "Base64 payload should be non-empty");
Assert.AreEqual(64, result.Width, "Width should match the requested size");
Assert.AreEqual(64, result.Height, "Height should match the requested size");
Assert.AreEqual("png", result.Encoding);
Assert.AreEqual($"camera:{CameraName}", result.Source);
Assert.IsNull(result.SavedPath, "No savePath was requested");
var bytes = Convert.FromBase64String(result.Base64);
AssertPngSignature(bytes);
Assert.AreEqual(bytes.Length, result.Bytes, "Reported byte length should match decoded payload");
}
[Test]
public void CaptureSceneView_ReturnsPng()
{
if (IsHeadless)
Assert.Ignore("No GPU in batchmode");
var sv = SceneView.lastActiveSceneView;
if (sv == null)
Assert.Ignore("No Scene View");
var result = CaptureCommands.CaptureSceneView(64, 64);
Assert.IsNotNull(result, "Capture should return a result");
Assert.IsNotEmpty(result.Base64, "Base64 payload should be non-empty");
Assert.AreEqual("sceneView", result.Source);
var bytes = Convert.FromBase64String(result.Base64);
AssertPngSignature(bytes);
}
[Test]
public void CaptureGameView_SavePath_OmitsBase64_AndWritesPng()
{
if (IsHeadless)
Assert.Ignore("No GPU in batchmode");
var result = CaptureCommands.CaptureGameView(64, 64, CameraName, $"{SaveFolder}/path_only.png");
Assert.IsNull(result.Base64, "save_path without include_inline_image should omit the base64 payload");
Assert.IsNotNull(result.SavedPath, "SavedPath should be set");
var fileBytes = File.ReadAllBytes(AbsolutePath(result.SavedPath));
AssertPngSignature(fileBytes);
Assert.AreEqual(fileBytes.Length, result.Bytes, "Reported byte length should match the file");
}
[Test]
public void CaptureGameView_SavePath_IncludeImage_ReturnsFileAndBase64()
{
if (IsHeadless)
Assert.Ignore("No GPU in batchmode");
var result = CaptureCommands.CaptureGameView(64, 64, CameraName, $"{SaveFolder}/both.png", includeInlineImage: true);
Assert.IsNotEmpty(result.Base64, "include_inline_image=true should return the base64 payload");
Assert.IsNotNull(result.SavedPath, "SavedPath should be set");
AssertPngSignature(Convert.FromBase64String(result.Base64));
AssertPngSignature(File.ReadAllBytes(AbsolutePath(result.SavedPath)));
}
[Test]
public void CaptureGameView_MaxResolution_ClampsInlineRender()
{
if (IsHeadless)
Assert.Ignore("No GPU in batchmode");
// No save_path: the inline image is the only artifact, so the cap applies to the render.
var result = CaptureCommands.CaptureGameView(128, 64, CameraName, maxResolution: 32);
Assert.AreEqual(32, result.Width, "Long edge should be clamped to max_resolution");
Assert.AreEqual(16, result.Height, "Short edge should keep the aspect ratio");
AssertPngSignature(Convert.FromBase64String(result.Base64));
}
[Test]
public void CaptureGameView_SavePath_MaxResolution_DownscalesInlineOnly()
{
if (IsHeadless)
Assert.Ignore("No GPU in batchmode");
var result = CaptureCommands.CaptureGameView(128, 64, CameraName, $"{SaveFolder}/full.png",
includeInlineImage: true, maxResolution: 32);
Assert.AreEqual(128, result.Width, "The saved file keeps the requested resolution");
Assert.AreEqual(32, result.InlineWidth, "The inline copy is downscaled to max_resolution");
Assert.AreEqual(16, result.InlineHeight, "The inline copy keeps the aspect ratio");
AssertPngSignature(Convert.FromBase64String(result.Base64));
AssertPngSignature(File.ReadAllBytes(AbsolutePath(result.SavedPath)));
}
[Test]
public void CaptureSceneView_SavePath_OmitsBase64_AndWritesPng()
{
if (IsHeadless)
Assert.Ignore("No GPU in batchmode");
var sv = SceneView.lastActiveSceneView;
if (sv == null)
Assert.Ignore("No Scene View");
var result = CaptureCommands.CaptureSceneView(64, 64, $"{SaveFolder}/scene_path_only.png");
Assert.IsNull(result.Base64, "save_path without include_inline_image should omit the base64 payload");
Assert.IsNotNull(result.SavedPath, "SavedPath should be set");
AssertPngSignature(File.ReadAllBytes(AbsolutePath(result.SavedPath)));
}
#endregion
#region ViaClient
[Test]
public void CaptureGameView_ViaClient_Succeeds()
{
if (IsHeadless)
Assert.Ignore("No GPU in batchmode");
using (var server = new PipelineTestServer())
{
var response = server.Execute("capture_game_view", new { width = 64, height = 64, camera = CameraName });
Assert.IsTrue(response.IsSuccess, $"capture_game_view should succeed: {response.Error}");
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field");
}
}
[Test]
public void CaptureGameView_ViaClient_SavePath_SerializedResultOmitsBase64Key()
{
if (IsHeadless)
Assert.Ignore("No GPU in batchmode");
using (var server = new PipelineTestServer())
{
var response = server.Execute("capture_game_view",
new { width = 64, height = 64, camera = CameraName, save_path = $"{SaveFolder}/via_client.png" });
Assert.IsTrue(response.IsSuccess, $"capture_game_view should succeed: {response.Error}");
var result = (JObject)response.JsonResponse["result"];
Assert.IsFalse(result.ContainsKey("base64"),
"A path-only result must not carry a base64 key on the wire (AUTHAPI-8)");
Assert.IsNotNull(result["savedPath"]?.ToString(), "savedPath should be present");
}
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7469ce0ae01ac444280398a19366fdfc
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,243 @@
#if UNITY_6000_7_OR_NEWER
using System.IO;
using System.Linq;
using NUnit.Framework;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor;
using Unity.Pipeline.Editor.Commands;
using Unity.Pipeline.Runtime.Commands;
using UnityEditor;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.UIElements;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Tests for the visual-element capture commands. The selector resolver
/// (<see cref="VisualElementCaptureSupport.ResolveElement(VisualElement,string)"/>) is the
/// riskiest new logic and is GPU-independent, so it gets the most coverage; the
/// <c>capture_editor_element</c> command is covered for its validation/miss branches plus a
/// GPU-gated happy path.
/// </summary>
public class CaptureVisualElementCommandsTests
{
// -- Selector resolver -------------------------------------------------------------------
static VisualElement BuildTree()
{
// root
// #toolbar .toolbar
// #search Button .primary (direct child of toolbar)
// #content
// #title Label .big (disabled)
var root = new VisualElement { name = "root" };
var toolbar = new VisualElement { name = "toolbar" };
toolbar.AddToClassList("toolbar");
var search = new Button { name = "search" };
search.AddToClassList("primary");
toolbar.Add(search);
var content = new VisualElement { name = "content" };
var title = new Label { name = "title" };
title.AddToClassList("big");
title.SetEnabled(false); // sets the :disabled pseudo-state
content.Add(title);
root.Add(toolbar);
root.Add(content);
return root;
}
[Test]
public void Resolve_ById()
{
var root = BuildTree();
var e = VisualElementCaptureSupport.ResolveElement(root, "#search");
Assert.IsNotNull(e);
Assert.AreEqual("search", e.name);
}
[Test]
public void Resolve_ByClass()
{
var root = BuildTree();
var e = VisualElementCaptureSupport.ResolveElement(root, ".toolbar");
Assert.IsNotNull(e);
Assert.AreEqual("toolbar", e.name);
}
[Test]
public void Resolve_ByType()
{
var root = BuildTree();
var e = VisualElementCaptureSupport.ResolveElement(root, "Label");
Assert.IsNotNull(e);
Assert.AreEqual("title", e.name);
Assert.IsInstanceOf<Label>(e);
}
[Test]
public void Resolve_TypeWithNameAndClass()
{
var root = BuildTree();
var e = VisualElementCaptureSupport.ResolveElement(root, "Button#search.primary");
Assert.IsNotNull(e);
Assert.AreEqual("search", e.name);
Assert.IsInstanceOf<Button>(e);
}
[Test]
public void Resolve_DescendantChain()
{
var root = BuildTree();
var e = VisualElementCaptureSupport.ResolveElement(root, ".toolbar #search");
Assert.IsNotNull(e);
Assert.AreEqual("search", e.name);
}
[Test]
public void Resolve_ChildCombinator()
{
var root = BuildTree();
// search IS a direct child of toolbar.
Assert.IsNotNull(VisualElementCaptureSupport.ResolveElement(root, "#toolbar > #search"));
// title is NOT a direct child of toolbar, so the child combinator must miss.
Assert.IsNull(VisualElementCaptureSupport.ResolveElement(root, "#toolbar > #title"));
}
[Test]
public void Resolve_PseudoDisabledAndEnabled()
{
var root = BuildTree();
Assert.IsNotNull(VisualElementCaptureSupport.ResolveElement(root, "#title:disabled"),
"title is disabled, so :disabled should match");
Assert.IsNull(VisualElementCaptureSupport.ResolveElement(root, "#title:enabled"),
"title is disabled, so :enabled should not match");
Assert.IsNull(VisualElementCaptureSupport.ResolveElement(root, "#title:not(disabled)"),
":not(disabled) should not match a disabled element");
}
[Test]
public void Resolve_Miss_ReturnsNull()
{
var root = BuildTree();
Assert.IsNull(VisualElementCaptureSupport.ResolveElement(root, "#does-not-exist"));
}
[Test]
public void Resolve_InvalidSelector_ReturnsNull()
{
var root = BuildTree();
Assert.IsNull(VisualElementCaptureSupport.ResolveElement(root, "#"), "dangling '#' is invalid");
Assert.IsNull(VisualElementCaptureSupport.ResolveElement(root, ""), "empty selector");
}
// -- capture_editor_element command ------------------------------------------------------
[Test]
public void EditorCommand_IsDiscovered()
{
CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
var cmd = CommandRegistry.DiscoverCommands().FirstOrDefault(c => c.Name == "capture_editor_element");
Assert.IsNotNull(cmd, "capture_editor_element should be discovered");
Assert.IsTrue(cmd.MainThreadRequired);
}
[Test]
public void RuntimeCommand_IsDiscovered_AndRuntimeOnly()
{
CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
var cmd = CommandRegistry.DiscoverCommands().FirstOrDefault(c => c.Name == "capture_runtime_element");
Assert.IsNotNull(cmd, "capture_runtime_element should be discovered");
Assert.IsTrue(cmd.RuntimeOnly, "runtime capture command should be RuntimeOnly");
}
[Test]
public void EditorCommand_MissingWindow_Fails()
{
var result = CaptureEditorElementCommand.CaptureEditorElement(window: "", selector: "#x");
Assert.IsFalse(result.Success);
StringAssert.Contains("window", result.Message);
}
[Test]
public void EditorCommand_UnknownWindow_Fails()
{
var result = CaptureEditorElementCommand.CaptureEditorElement(
window: "ThisWindowTypeDoesNotExist12345", selector: "#x");
Assert.IsFalse(result.Success);
StringAssert.Contains("No open EditorWindow", result.Message);
}
[Test]
public void EditorCommand_SelectorMiss_Fails()
{
var win = ScriptableObject.CreateInstance<TestCaptureWindow>();
try
{
win.Show();
var result = CaptureEditorElementCommand.CaptureEditorElement(
window: nameof(TestCaptureWindow), selector: "#no-such-element");
Assert.IsFalse(result.Success);
StringAssert.Contains("No element matched", result.Message);
}
finally
{
win.Close();
}
}
[Test]
public void EditorCommand_HappyPath_WritesPngAndBase64()
{
if (SystemInfo.graphicsDeviceType == GraphicsDeviceType.Null)
Assert.Ignore("No GPU available; capture readback is not meaningful in this environment.");
var win = ScriptableObject.CreateInstance<TestCaptureWindow>();
var output = Path.Combine(Path.GetTempPath(), $"pipeline_ve_{System.Guid.NewGuid():N}.png");
try
{
win.Show();
var result = CaptureEditorElementCommand.CaptureEditorElement(
window: nameof(TestCaptureWindow), selector: "#target", output: output);
Assert.IsTrue(result.Success, result.Message);
Assert.AreEqual("png", result.Encoding);
Assert.Greater(result.Width, 0);
Assert.Greater(result.Height, 0);
Assert.IsFalse(string.IsNullOrEmpty(result.Base64), "base64 payload should be populated");
Assert.Greater(result.Bytes, 0);
Assert.AreEqual(output, result.Path);
FileAssert.Exists(output);
}
finally
{
win.Close();
if (File.Exists(output))
File.Delete(output);
}
}
/// <summary>A minimal EditorWindow with one named, sized element to capture.</summary>
class TestCaptureWindow : EditorWindow
{
void CreateGUI()
{
var target = new VisualElement
{
name = "target",
style =
{
width = 120,
height = 60,
backgroundColor = Color.red
}
};
rootVisualElement.Add(target);
}
}
}
}
#endif
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 79d62a430827aa6449a37bbbb889e26c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,247 @@
using NUnit.Framework;
using Unity.Pipeline.Models;
using Unity.Pipeline.Runtime.Commands;
using Unity.Pipeline.Tests;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Tests for the eval command (CodeEvalCommand), exercised directly and via PipelineClient.
/// Compiler-level behavior (EvalCodeCompiler) is covered by EvalCodeCompilerTests.
/// </summary>
public class CodeEvalCommandTests
{
#region Direct
[Test]
public void EvaluateCode_SimpleArithmetic_ReturnsResult()
{
var r = CodeEvalCommand.EvaluateCode("return 2 + 2;");
Assert.IsTrue(r.Success, r.Error);
Assert.AreEqual(4, r.Result);
}
[Test]
public void EvaluateCode_StringExpression_ReturnsString()
{
var r = CodeEvalCommand.EvaluateCode("return \"Hello World\";");
Assert.IsTrue(r.Success, r.Error);
Assert.AreEqual("Hello World", r.Result);
}
[Test]
public void EvaluateCode_UnityApi_ReturnsVersion()
{
var r = CodeEvalCommand.EvaluateCode("return Application.unityVersion;");
Assert.IsTrue(r.Success, r.Error);
Assert.IsInstanceOf<string>(r.Result);
}
[Test]
public void EvaluateCode_DebugLog_ReturnsExplicitValue()
{
var r = CodeEvalCommand.EvaluateCode("Debug.Log(\"x\"); return \"logged\";");
Assert.IsTrue(r.Success, r.Error);
Assert.AreEqual("logged", r.Result);
}
[Test]
public void EvaluateCode_EmptyCode_BadRequest()
{
var r = CodeEvalCommand.EvaluateCode("");
Assert.IsFalse(r.Success);
Assert.AreEqual("Bad Request", r.Error);
}
[Test]
public void EvaluateCode_NullCode_BadRequest()
{
var r = CodeEvalCommand.EvaluateCode(null);
Assert.IsFalse(r.Success);
Assert.AreEqual("Bad Request", r.Error);
}
[Test]
public void EvaluateCode_SyntaxError_CompilationFailed()
{
var r = CodeEvalCommand.EvaluateCode("return 2 +;");
Assert.IsFalse(r.Success);
Assert.AreEqual("Compilation Failed", r.Error);
Assert.Greater(r.Diagnostics.Count, 0);
}
[Test]
public void EvaluateCode_RuntimeException_Fails()
{
var r = CodeEvalCommand.EvaluateCode("throw new System.Exception(\"boom\");");
Assert.IsFalse(r.Success);
Assert.IsNotNull(r.ErrorDetails);
}
[Test]
public void EvaluateCode_ZeroTimeout_BadRequest()
{
var r = CodeEvalCommand.EvaluateCode("return 1;", timeout: 0);
Assert.IsFalse(r.Success);
Assert.AreEqual("Bad Request", r.Error);
}
[Test]
public void EvaluateCode_ExcessiveTimeout_BadRequest()
{
var r = CodeEvalCommand.EvaluateCode("return 1;", timeout: 40000);
Assert.IsFalse(r.Success);
Assert.AreEqual("Bad Request", r.Error);
}
[Test]
public void EvaluateCode_RecordsExecutionTime()
{
var r = CodeEvalCommand.EvaluateCode("return 42;", timeout: 5000);
Assert.IsTrue(r.Success, r.Error);
Assert.Greater(r.ExecutionTimeMs, 0);
}
#endregion
#region EvalFile
[Test]
public void EvaluateFile_FromFile_ReturnsResult()
{
var path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
"eval_test_" + System.Guid.NewGuid().ToString("N") + ".cs");
System.IO.File.WriteAllText(path, "return 2 + 2;");
try
{
var r = CodeEvalCommand.EvaluateFile(path);
Assert.IsTrue(r.Success, r.Error);
Assert.AreEqual(4, r.Result);
}
finally
{
System.IO.File.Delete(path);
}
}
[Test]
public void EvaluateFile_FileNotFound_BadRequest()
{
var path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
"eval_missing_" + System.Guid.NewGuid().ToString("N") + ".cs");
var r = CodeEvalCommand.EvaluateFile(path);
Assert.IsFalse(r.Success);
Assert.AreEqual("Bad Request", r.Error);
}
[Test]
public void EvaluateFile_NonCsFile_BadRequest()
{
var path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
"eval_test_" + System.Guid.NewGuid().ToString("N") + ".txt");
System.IO.File.WriteAllText(path, "return 2 + 2;");
try
{
var r = CodeEvalCommand.EvaluateFile(path);
Assert.IsFalse(r.Success);
Assert.AreEqual("Bad Request", r.Error);
}
finally
{
System.IO.File.Delete(path);
}
}
[Test]
public void EvaluateFile_EmptyFile_BadRequest()
{
var path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
"eval_test_" + System.Guid.NewGuid().ToString("N") + ".cs");
System.IO.File.WriteAllText(path, " ");
try
{
var r = CodeEvalCommand.EvaluateFile(path);
Assert.IsFalse(r.Success);
Assert.AreEqual("Bad Request", r.Error);
}
finally
{
System.IO.File.Delete(path);
}
}
[Test]
public void EvaluateFile_NullFile_BadRequest()
{
var r = CodeEvalCommand.EvaluateFile(null);
Assert.IsFalse(r.Success);
Assert.AreEqual("Bad Request", r.Error);
}
#endregion
#region ViaClient
[Test]
public void Eval_ViaClient_ReturnsResult()
{
using (var server = new PipelineTestServer())
{
// The test client authenticates with the server's bearer token.
var response = server.Execute("eval", new { code = "return Application.platform.ToString();", timeout = 5000 });
Assert.IsTrue(response.IsSuccess, response.Error);
var r = response.GetTypedResponse<EvalResponse>();
Assert.IsNotNull(r, "Should deserialize an EvalResponse");
Assert.IsTrue(r.Success, r.Error);
Assert.IsInstanceOf<string>(r.Result);
}
}
[Test]
public void Eval_ViaClient_SyntaxError_CompilationFailed()
{
using (var server = new PipelineTestServer())
{
var response = server.Execute("eval", new { code = "return 2 +;", timeout = 5000 });
var r = response.GetTypedResponse<EvalResponse>();
Assert.IsNotNull(r, "Should deserialize an EvalResponse");
Assert.IsFalse(r.Success);
Assert.AreEqual("Compilation Failed", r.Error);
}
}
[Test]
public void EvalFile_ViaClient_ReturnsResult()
{
var path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
"eval_test_" + System.Guid.NewGuid().ToString("N") + ".cs");
System.IO.File.WriteAllText(path, "return 6 * 7;");
try
{
using (var server = new PipelineTestServer())
{
var response = server.Execute("eval_file", new { file = path, timeout = 5000 });
Assert.IsTrue(response.IsSuccess, response.Error);
var r = response.GetTypedResponse<EvalResponse>();
Assert.IsNotNull(r, "Should deserialize an EvalResponse");
Assert.IsTrue(r.Success, r.Error);
Assert.AreEqual(42, r.Result);
}
}
finally
{
System.IO.File.Delete(path);
}
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0919730cfeec19e40b46ecce9479cf70
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,303 @@
using System.Linq;
using System.Reflection;
using NUnit.Framework;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Tests for command registration system using [CliCommand] and [CliArg] attributes.
/// These test the attribute-based command definition that CLI tools will discover.
/// </summary>
public class CommandRegistrationTests
{
[SetUp]
public void SetUp()
{
// Ensure TypeCache discovery is available for tests
CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
}
[Test]
public void CliCommandAttribute_AppliedToMethod_CanBeRetrievedViaReflection()
{
// Arrange - Get the test command method (private static, resolved via reflection)
var methodInfo = typeof(DummyCommands).GetMethod("TestLogCommand",
BindingFlags.NonPublic | BindingFlags.Static);
Assert.IsNotNull(methodInfo, "Test command method should exist");
// Act - Try to get the CliCommand attribute
var attribute = methodInfo.GetCustomAttribute<CliCommandAttribute>();
// Assert - Attribute should be present and contain expected data
Assert.IsNotNull(attribute, "Method should have CliCommand attribute");
Assert.AreEqual("log_editor", attribute.Name, "Command name should match attribute");
Assert.AreEqual("Log a message to Unity Editor console", attribute.Description,
"Command description should match attribute");
}
[Test]
public void CliArgAttribute_AppliedToParameter_CanBeRetrievedViaReflection()
{
// Arrange - Get the test command method and its parameters (private static)
var methodInfo = typeof(DummyCommands).GetMethod("TestLogCommand",
BindingFlags.NonPublic | BindingFlags.Static);
var parameters = methodInfo.GetParameters();
Assert.AreEqual(1, parameters.Length, "Test command should have one parameter");
// Act - Try to get the CliArg attribute from the parameter
var parameter = parameters[0];
var attribute = parameter.GetCustomAttribute<CliArgAttribute>();
// Assert - Parameter attribute should be present and contain expected data
Assert.IsNotNull(attribute, "Parameter should have CliArg attribute");
Assert.AreEqual("message", attribute.Name, "Parameter name should match attribute");
Assert.AreEqual("The message to log", attribute.Description,
"Parameter description should match attribute");
Assert.IsTrue(attribute.Required, "Parameter should be marked as required");
}
[Test]
public void CommandRegistry_DiscoverCommands_FindsRegisteredCommands()
{
// Arrange & Act - Discover all commands via CommandRegistry
var commands = CommandRegistry.DiscoverCommands();
// Assert - Should find our test command
Assert.IsNotNull(commands, "Command discovery should return a collection");
Assert.Greater(commands.Count(), 0, "Should discover at least one command");
// Find our specific test command
var testCommand = commands.FirstOrDefault(cmd => cmd.Name == "log_editor");
Assert.IsNotNull(testCommand, "Should discover the log_editor test command");
Assert.AreEqual("Log a message to Unity Editor console", testCommand.Description);
Assert.IsTrue(testCommand.MainThreadRequired, "Test command should require main thread");
// Verify parameter information
Assert.AreEqual(1, testCommand.Parameters.Count, "Command should have one parameter");
var messageParam = testCommand.Parameters.First();
Assert.AreEqual("message", messageParam.Name);
Assert.AreEqual("The message to log", messageParam.Description);
Assert.IsTrue(messageParam.Required);
Assert.AreEqual(typeof(string), messageParam.ParameterType);
}
[Test]
public void CommandRegistry_DiscoverCommands_PopulatesRuntimeOnlyFlag()
{
// Arrange & Act
var commands = CommandRegistry.DiscoverCommands().ToList();
// Assert - runtime commands are tagged RuntimeOnly, editor commands are not
var editorStatus = commands.First(c => c.Name == "editor_status");
var runtimeStatus = commands.First(c => c.Name == "runtime_status");
Assert.IsTrue(runtimeStatus.RuntimeOnly, "eval should be marked RuntimeOnly");
Assert.IsFalse(editorStatus.RuntimeOnly, "editor_status should not be RuntimeOnly");
}
[Test]
public void JsonSchemaGeneration_FromCommandInfo_CreatesValidSchema()
{
// Arrange - Get our test command
var commands = CommandRegistry.DiscoverCommands();
var testCommand = commands.First(cmd => cmd.Name == "log_editor");
// Act - Generate JSON schema for the command
var schema = JsonSchemaGenerator.GenerateCommandSchema(testCommand);
// Assert - Schema should be valid JSON Schema format
Assert.IsNotNull(schema, "Schema should not be null");
// Parse as JSON to verify structure
var schemaJson = JObject.Parse(schema);
// Verify root schema properties
Assert.AreEqual("object", schemaJson["type"]?.ToString());
Assert.IsNotNull(schemaJson["properties"], "Schema should have properties section");
Assert.IsNotNull(schemaJson["required"], "Schema should have required array");
// Verify command metadata
Assert.AreEqual("log_editor", schemaJson["title"]?.ToString());
Assert.AreEqual("Log a message to Unity Editor console", schemaJson["description"]?.ToString());
// Verify parameter schema
var properties = schemaJson["properties"] as JObject;
Assert.IsNotNull(properties["message"], "Should have message parameter");
var messageProperty = properties["message"] as JObject;
Assert.AreEqual("string", messageProperty["type"]?.ToString());
Assert.AreEqual("The message to log", messageProperty["description"]?.ToString());
// Verify required array
var required = schemaJson["required"] as JArray;
Assert.Contains("message", required.ToObject<string[]>());
}
[Test]
public void JsonSchemaGeneration_MultipleParameterTypes_MapsTypesCorrectly()
{
// Arrange - Get test command with multiple parameter types
var commands = CommandRegistry.DiscoverCommands();
var testCommand = commands.First(cmd => cmd.Name == "test_types");
// Act - Generate schema
var schema = JsonSchemaGenerator.GenerateCommandSchema(testCommand);
var schemaJson = JObject.Parse(schema);
// Assert - Verify different parameter types are mapped correctly
var properties = schemaJson["properties"] as JObject;
// Integer parameter
var countProperty = properties["count"] as JObject;
Assert.AreEqual("integer", countProperty["type"]?.ToString());
Assert.AreEqual(1, countProperty["default"]?.ToObject<int>());
// Boolean parameter
var enabledProperty = properties["enabled"] as JObject;
Assert.AreEqual("boolean", enabledProperty["type"]?.ToString());
Assert.AreEqual(false, enabledProperty["default"]?.ToObject<bool>());
// Float parameter
var factorProperty = properties["factor"] as JObject;
Assert.AreEqual("number", factorProperty["type"]?.ToString());
Assert.AreEqual(1.0f, factorProperty["default"]?.ToObject<float>());
// Verify no required parameters (all have defaults)
var required = schemaJson["required"] as JArray;
Assert.AreEqual(0, required.Count, "All parameters should be optional with default values");
}
[Test]
public void JsonSchemaGeneration_StructuredObjectParameter_EmitsNestedObjectSchema()
{
// Arrange - command whose single parameter is an IStructuredCommandInput DTO
var commands = CommandRegistry.DiscoverCommands();
var testCommand = commands.First(cmd => cmd.Name == "test_structured");
// Act
var schemaJson = JObject.Parse(JsonSchemaGenerator.GenerateCommandSchema(testCommand));
// Assert - the payload param is a real nested object schema, not a "string" fallback
var payload = schemaJson["properties"]?["payload"] as JObject;
Assert.IsNotNull(payload, "payload parameter should be present");
Assert.AreEqual("object", payload["type"]?.ToString(), "structured param should map to object");
Assert.AreEqual(false, payload["additionalProperties"]?.ToObject<bool>());
var props = payload["properties"] as JObject;
Assert.IsNotNull(props, "nested object should expose its own properties");
// Scalar member with description from [CliArg]
Assert.AreEqual("string", props["name"]?["type"]?.ToString());
Assert.AreEqual("Display name", props["name"]?["description"]?.ToString());
Assert.AreEqual("integer", props["count"]?["type"]?.ToString());
// Array member emits items schema
Assert.AreEqual("array", props["tags"]?["type"]?.ToString());
Assert.AreEqual("string", props["tags"]?["items"]?["type"]?.ToString());
// Nested structured member recurses
var nested = props["nested"] as JObject;
Assert.IsNotNull(nested, "nested structured member should be present");
Assert.AreEqual("object", nested["type"]?.ToString());
Assert.AreEqual("boolean", nested["properties"]?["enabled"]?["type"]?.ToString());
// Required propagates from [CliArg(Required = true)] on a member
var required = payload["required"]?.ToObject<string[]>();
Assert.IsNotNull(required, "structured object should have a required array");
CollectionAssert.Contains(required, "name");
}
[Test]
public void EditorStatusCommand_IsDiscovered_ByCommandRegistry()
{
// Arrange & Act - Discover all commands
var commands = CommandRegistry.DiscoverCommands();
// Assert - Should find the editor_status command
var editorStatusCommand = commands.FirstOrDefault(cmd => cmd.Name == "editor_status");
Assert.IsNotNull(editorStatusCommand, "Should discover the editor_status command");
Assert.AreEqual("Get detailed Unity Editor status and state information", editorStatusCommand.Description);
Assert.IsTrue(editorStatusCommand.MainThreadRequired, "editor_status should require main thread");
// Verify it has no parameters (editor status doesn't need input)
Assert.AreEqual(0, editorStatusCommand.Parameters.Count, "editor_status should have no parameters");
}
[Test]
public void EditorStatusCommand_DirectExecution_WorksCorrectly()
{
// Arrange & Act - Execute editor_status command directly (bypass all HTTP and threading)
try
{
var result = Unity.Pipeline.Editor.Commands.EditorStatusCommand.GetEditorStatus();
// Assert
Assert.IsNotNull(result, "editor_status command should return a result");
Assert.IsInstanceOf<Unity.Pipeline.Models.StatusResponse>(result, "Should return StatusResponse type");
var status = result as Unity.Pipeline.Models.StatusResponse;
Assert.IsNotNull(status.Status, "Status should have Status field");
Assert.IsNotNull(status.UnityVersion, "Status should have UnityVersion field");
}
catch (System.Exception)
{
throw;
}
}
}
/// <summary>
/// Test commands for verifying command registration attributes.
/// </summary>
public static class DummyCommands
{
// Private on purpose: verifies the registry can discover and execute
// non-public static methods (see CommandRegistry.CreateCommandInfo).
[CliCommand("log_editor", "Log a message to Unity Editor console")]
private static void TestLogCommand([CliArg("message", "The message to log", Required = true)] string message)
{
UnityEngine.Debug.Log(message);
}
[CliCommand("test_types", "Test command with various parameter types")]
public static void TestTypesCommand(
[CliArg("count", "Number of items")] int count = 1,
[CliArg("enabled", "Whether feature is enabled")] bool enabled = false,
[CliArg("factor", "Multiplier factor")] float factor = 1.0f)
{
// Test command with various types
}
[CliCommand("test_structured", "Test command with a structured object parameter")]
public static void TestStructuredCommand(
[CliArg("payload", "Structured payload")] SampleStructuredInput payload = null)
{
// Test command with a nested-object parameter
}
}
/// <summary>Structured-input DTO used to verify nested object schema generation.</summary>
public class SampleStructuredInput : IStructuredCommandInput
{
[CliArg("name", "Display name", Required = true)]
public string Name { get; set; }
[CliArg("count", "How many")]
public int Count { get; set; }
[CliArg("tags", "Associated tags")]
public string[] Tags { get; set; }
[CliArg("nested", "Nested child object")]
public SampleNestedInput Nested { get; set; }
}
public class SampleNestedInput : IStructuredCommandInput
{
[CliArg("enabled", "Toggle")]
public bool Enabled { get; set; }
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9a2753687e13238458ed342206a00d72
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,119 @@
using System;
using System.Linq;
using NUnit.Framework;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Console;
using Unity.Pipeline.Editor;
using Unity.Pipeline.Runtime.Commands;
using UnityEngine;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Tests for the console command (<see cref="ConsoleCommand"/>).
///
/// Entries are injected straight into the shared buffer via <see cref="ConsoleLogBuffer.Add"/>
/// (rather than real Debug.Log calls) so the tests are deterministic and don't trip Unity's
/// LogAssert on error-level logs. Each test tags its entries with a unique marker and filters on
/// it, so unrelated editor logs captured concurrently don't affect assertions.
/// </summary>
public class ConsoleCommandTests
{
string m_Marker;
[SetUp]
public void SetUp()
{
ConsoleLogCapture.Buffer.Clear();
m_Marker = $"[ectest-{Guid.NewGuid():N}] ";
}
string[] MineInOrder(Unity.Pipeline.Models.ConsoleLogResponse response)
{
return response.Entries
.Where(e => e.Message != null && e.Message.StartsWith(m_Marker))
.Select(e => e.Message.Substring(m_Marker.Length))
.ToArray();
}
void Add(LogType type, string message) =>
ConsoleLogCapture.Buffer.Add(type, m_Marker + message, null, DateTime.UtcNow);
[Test]
public void GetConsole_Default_ReturnsRecentEntriesInOrder()
{
Add(LogType.Log, "one");
Add(LogType.Log, "two");
Add(LogType.Log, "three");
var response = ConsoleCommand.GetConsole();
CollectionAssert.AreEqual(new[] { "one", "two", "three" }, MineInOrder(response));
}
[Test]
public void GetConsole_LevelError_ReturnsOnlyErrorSeverity()
{
Add(LogType.Log, "plain");
Add(LogType.Warning, "careful");
Add(LogType.Error, "broken");
Add(LogType.Exception, "threw");
var response = ConsoleCommand.GetConsole(level: "error");
CollectionAssert.AreEqual(new[] { "broken", "threw" }, MineInOrder(response));
}
[Test]
public void GetConsole_LevelWarn_IncludesWarningsAndErrors()
{
Add(LogType.Log, "plain");
Add(LogType.Warning, "careful");
Add(LogType.Error, "broken");
var response = ConsoleCommand.GetConsole(level: "warn");
CollectionAssert.AreEqual(new[] { "careful", "broken" }, MineInOrder(response));
}
[Test]
public void GetConsole_Since_ReturnsOnlyNewerEntries()
{
Add(LogType.Log, "before");
long cursor = ConsoleCommand.GetConsole().Cursor;
Add(LogType.Log, "after");
var response = ConsoleCommand.GetConsole(since: cursor);
CollectionAssert.AreEqual(new[] { "after" }, MineInOrder(response));
}
[Test]
public void GetConsole_TailZero_IsCoercedToDefault()
{
Add(LogType.Log, "a");
Add(LogType.Log, "b");
// tail <= 0 must not mean "unlimited"; it falls back to the default and still returns ours.
var response = ConsoleCommand.GetConsole(tail: 0);
Assert.LessOrEqual(response.Returned, ConsoleCommand.DefaultTail);
CollectionAssert.AreEqual(new[] { "a", "b" }, MineInOrder(response));
}
[Test]
public void Console_IsDiscoverableWithExpectedArgs()
{
CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
var command = CommandRegistry.DiscoverCommands().FirstOrDefault(c => c.Name == "console");
Assert.IsNotNull(command, "console should be discoverable");
Assert.IsFalse(command.MainThreadRequired, "console reads the buffer only; no main thread needed");
var paramNames = command.Parameters.Select(p => p.Name).ToList();
CollectionAssert.Contains(paramNames, "tail");
CollectionAssert.Contains(paramNames, "level");
CollectionAssert.Contains(paramNames, "since");
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 54c331b153b587c60e89bfbde9bdca1d
@@ -0,0 +1,310 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Newtonsoft.Json;
using NUnit.Framework;
using Unity.Pipeline.Console;
using Unity.Pipeline.Models;
using UnityEngine;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Tests for <see cref="ConsoleLogBuffer"/> — the ring buffer behind the console command.
/// Exercised directly (no Unity log capture) for determinism.
/// </summary>
public class ConsoleLogBufferTests
{
static readonly DateTime k_Ts = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
static ConsoleLogBuffer NewBuffer() => new ConsoleLogBuffer();
[Test]
public void Add_AssignsIncreasingSequenceNumbers()
{
var buffer = NewBuffer();
buffer.Add(LogType.Log, "a", null, k_Ts);
buffer.Add(LogType.Log, "b", null, k_Ts);
buffer.Add(LogType.Log, "c", null, k_Ts);
var entries = buffer.Query(-1, 100, ConsoleLogBuffer.SeverityLog).Entries;
CollectionAssert.AreEqual(new[] { "a", "b", "c" }, entries.Select(e => e.Message));
CollectionAssert.AreEqual(new long[] { 1, 2, 3 }, entries.Select(e => e.Seq));
}
[Test]
public void Query_Snapshot_ReturnsOldestFirst()
{
var buffer = NewBuffer();
buffer.Add(LogType.Log, "first", null, k_Ts);
buffer.Add(LogType.Log, "second", null, k_Ts);
var response = buffer.Query(-1, 100, ConsoleLogBuffer.SeverityLog);
Assert.AreEqual(2, response.Returned);
Assert.AreEqual("first", response.Entries[0].Message);
Assert.AreEqual("second", response.Entries[1].Message);
Assert.AreEqual(2, response.Cursor);
Assert.IsFalse(response.Dropped);
}
[Test]
public void Query_LevelThreshold_IsMinimumSeverity()
{
var buffer = NewBuffer();
buffer.Add(LogType.Log, "log", null, k_Ts);
buffer.Add(LogType.Warning, "warn", null, k_Ts);
buffer.Add(LogType.Error, "error", null, k_Ts);
buffer.Add(LogType.Exception, "exception", null, k_Ts);
var warnAndUp = buffer.Query(-1, 100, ConsoleLogBuffer.SeverityWarn).Entries;
CollectionAssert.AreEqual(new[] { "warn", "error", "exception" }, warnAndUp.Select(e => e.Message));
var errorOnly = buffer.Query(-1, 100, ConsoleLogBuffer.SeverityError).Entries;
CollectionAssert.AreEqual(new[] { "error", "exception" }, errorOnly.Select(e => e.Message));
var all = buffer.Query(-1, 100, ConsoleLogBuffer.SeverityLog).Entries;
Assert.AreEqual(4, all.Length);
}
[Test]
public void Query_Tail_ReturnsMostRecentMatches()
{
var buffer = NewBuffer();
for (int i = 0; i < 10; i++)
buffer.Add(LogType.Log, $"m{i}", null, k_Ts);
var entries = buffer.Query(-1, 3, ConsoleLogBuffer.SeverityLog).Entries;
CollectionAssert.AreEqual(new[] { "m7", "m8", "m9" }, entries.Select(e => e.Message));
}
[Test]
public void Query_Tail_AppliesAfterLevelFilter()
{
var buffer = NewBuffer();
buffer.Add(LogType.Error, "e0", null, k_Ts);
buffer.Add(LogType.Log, "l0", null, k_Ts);
buffer.Add(LogType.Error, "e1", null, k_Ts);
buffer.Add(LogType.Log, "l1", null, k_Ts);
buffer.Add(LogType.Error, "e2", null, k_Ts);
// tail=2 over error-only matches => last two errors, not last two of all entries.
var entries = buffer.Query(-1, 2, ConsoleLogBuffer.SeverityError).Entries;
CollectionAssert.AreEqual(new[] { "e1", "e2" }, entries.Select(e => e.Message));
}
[Test]
public void Query_Since_ReturnsOnlyNewerEntries()
{
var buffer = NewBuffer();
buffer.Add(LogType.Log, "a", null, k_Ts); // seq 1
buffer.Add(LogType.Log, "b", null, k_Ts); // seq 2
buffer.Add(LogType.Log, "c", null, k_Ts); // seq 3
var response = buffer.Query(2, 100, ConsoleLogBuffer.SeverityLog);
CollectionAssert.AreEqual(new[] { "c" }, response.Entries.Select(e => e.Message));
Assert.AreEqual(3, response.Cursor);
Assert.IsFalse(response.Dropped);
}
[Test]
public void Query_SinceAtCursor_ReturnsEmptyButReportsCursor()
{
var buffer = NewBuffer();
buffer.Add(LogType.Log, "a", null, k_Ts);
buffer.Add(LogType.Log, "b", null, k_Ts);
var response = buffer.Query(2, 100, ConsoleLogBuffer.SeverityLog);
Assert.AreEqual(0, response.Returned);
Assert.AreEqual(2, response.Cursor, "Cursor must still advance so a follow client resumes correctly");
Assert.IsFalse(response.Dropped);
}
[Test]
public void Query_SinceBeyondCursor_DoesNotOverflowDroppedCheck()
{
var buffer = NewBuffer();
buffer.Add(LogType.Log, "a", null, k_Ts);
var response = buffer.Query(long.MaxValue, 100, ConsoleLogBuffer.SeverityLog);
Assert.AreEqual(0, response.Returned);
Assert.IsFalse(response.Dropped);
}
[Test]
public void Query_CursorReflectsLatestEvenWhenFilteredOut()
{
var buffer = NewBuffer();
buffer.Add(LogType.Error, "boom", null, k_Ts); // seq 1
buffer.Add(LogType.Log, "noise", null, k_Ts); // seq 2, filtered out by error level
var response = buffer.Query(1, 100, ConsoleLogBuffer.SeverityError);
Assert.AreEqual(0, response.Returned, "The only new entry is below the level threshold");
Assert.AreEqual(2, response.Cursor, "Cursor advances past the filtered entry to avoid re-scanning it");
}
[Test]
public void Eviction_DropsOldestAndFlagsDropped()
{
var buffer = NewBuffer();
int total = ConsoleLogBuffer.Capacity + 50;
for (int i = 0; i < total; i++)
buffer.Add(LogType.Log, $"m{i}", null, k_Ts);
Assert.AreEqual(ConsoleLogBuffer.Capacity, buffer.Count, "Buffer should be capped at Capacity");
// since=1 points before the evicted window, so the consumer missed entries.
var response = buffer.Query(1, ConsoleLogBuffer.Capacity, ConsoleLogBuffer.SeverityLog);
Assert.IsTrue(response.Dropped, "Querying from an evicted cursor should set Dropped");
// The most recent entry is always present.
var latest = buffer.Query(-1, 1, ConsoleLogBuffer.SeverityLog).Entries.Single();
Assert.AreEqual($"m{total - 1}", latest.Message);
}
[Test]
public void Dropped_FalseWhenSinceWithinRetainedWindow()
{
var buffer = NewBuffer();
for (int i = 0; i < 10; i++)
buffer.Add(LogType.Log, $"m{i}", null, k_Ts);
var response = buffer.Query(5, 100, ConsoleLogBuffer.SeverityLog);
Assert.IsFalse(response.Dropped);
}
[Test]
public void SaveAndLoad_RoundTripsEntriesAndCursor()
{
var path = Path.Combine("Temp", $"pipeline_console_test_{Guid.NewGuid():N}.json");
try
{
var original = NewBuffer();
original.Add(LogType.Log, "log", null, k_Ts);
original.Add(LogType.Warning, "warn", "trace", k_Ts);
original.Add(LogType.Error, "error", null, k_Ts);
original.Save(path);
var restored = NewBuffer();
Assert.IsTrue(restored.Load(path));
var entries = restored.Query(-1, 100, ConsoleLogBuffer.SeverityLog).Entries;
CollectionAssert.AreEqual(new[] { "log", "warn", "error" }, entries.Select(e => e.Message));
Assert.AreEqual(ConsoleLogBuffer.LevelWarn, entries[1].Level, "Severity must survive the round trip");
Assert.AreEqual("trace", entries[1].StackTrace);
// Sequence continues monotonically after a restore.
restored.Add(LogType.Log, "after", null, k_Ts);
Assert.AreEqual(4, restored.Query(3, 100, ConsoleLogBuffer.SeverityLog).Entries.Single().Seq);
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void Load_MissingFile_ReturnsFalseAndLeavesBufferEmpty()
{
var buffer = NewBuffer();
Assert.IsFalse(buffer.Load(Path.Combine("Temp", $"does_not_exist_{Guid.NewGuid():N}.json")));
Assert.AreEqual(0, buffer.Count);
}
[Test]
public void Load_ClampsRestoredEntriesToCapacityKeepingMostRecent()
{
var path = Path.Combine("Temp", $"pipeline_console_oversized_{Guid.NewGuid():N}.json");
try
{
Directory.CreateDirectory(Path.GetDirectoryName(path) ?? "Temp");
var entries = Enumerable.Range(1, ConsoleLogBuffer.Capacity + 5)
.Select(i => new ConsoleLogEntry
{
Seq = i,
TimestampUtc = k_Ts,
Level = ConsoleLogBuffer.LevelLog,
Message = $"m{i}",
StackTrace = string.Empty
})
.ToArray();
File.WriteAllText(path, JsonConvert.SerializeObject(new
{
lastSeq = (long)(ConsoleLogBuffer.Capacity + 5),
entries
}));
var buffer = NewBuffer();
Assert.IsTrue(buffer.Load(path));
Assert.AreEqual(ConsoleLogBuffer.Capacity, buffer.Count);
var restored = buffer.Query(-1, ConsoleLogBuffer.Capacity, ConsoleLogBuffer.SeverityLog).Entries;
Assert.AreEqual("m6", restored.First().Message);
Assert.AreEqual($"m{ConsoleLogBuffer.Capacity + 5}", restored.Last().Message);
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void Clear_EmptiesEntriesButKeepsSequenceMonotonic()
{
var buffer = NewBuffer();
buffer.Add(LogType.Log, "a", null, k_Ts);
buffer.Add(LogType.Log, "b", null, k_Ts);
buffer.Clear();
Assert.AreEqual(0, buffer.Count);
buffer.Add(LogType.Log, "c", null, k_Ts);
Assert.AreEqual(3, buffer.LastSeq, "Sequence must not reset on Clear");
}
[Test]
public void Add_IsThreadSafe()
{
var buffer = NewBuffer();
const int threads = 8;
const int perThread = 500;
var tasks = Enumerable.Range(0, threads).Select(t => Task.Run(() =>
{
for (int i = 0; i < perThread; i++)
buffer.Add(LogType.Log, $"t{t}-{i}", null, k_Ts);
})).ToArray();
Task.WaitAll(tasks);
Assert.AreEqual(threads * perThread, buffer.LastSeq, "Every add must get a unique sequence number");
Assert.AreEqual(ConsoleLogBuffer.Capacity, buffer.Count);
// All retained seqs are unique.
var seqs = buffer.Query(-1, ConsoleLogBuffer.Capacity, ConsoleLogBuffer.SeverityLog)
.Entries.Select(e => e.Seq).ToArray();
Assert.AreEqual(seqs.Length, seqs.Distinct().Count(), "Sequence numbers must be unique");
}
[Test]
public void SeverityFromLevelName_ParsesNamesAndAliases()
{
Assert.AreEqual(ConsoleLogBuffer.SeverityError, ConsoleLogBuffer.SeverityFromLevelName("error"));
Assert.AreEqual(ConsoleLogBuffer.SeverityError, ConsoleLogBuffer.SeverityFromLevelName("ERR"));
Assert.AreEqual(ConsoleLogBuffer.SeverityWarn, ConsoleLogBuffer.SeverityFromLevelName("warn"));
Assert.AreEqual(ConsoleLogBuffer.SeverityWarn, ConsoleLogBuffer.SeverityFromLevelName("Warning"));
Assert.AreEqual(ConsoleLogBuffer.SeverityLog, ConsoleLogBuffer.SeverityFromLevelName("log"));
Assert.AreEqual(ConsoleLogBuffer.SeverityLog, ConsoleLogBuffer.SeverityFromLevelName("nonsense"));
Assert.AreEqual(ConsoleLogBuffer.SeverityLog, ConsoleLogBuffer.SeverityFromLevelName(null));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 524e94027ab20453a5442be5e73f87a1
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9d0b41445c16baf4ca9e5090b4e4ce17
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,70 @@
using NUnit.Framework;
namespace Unity.Pipeline.Tests.Editor.E2E
{
// =====================================================================================
// CLI-196 — End-to-end agent authoring test (SPEC / SKELETON — BLOCKED)
//
// This file is intentionally inert. It compiles against NUnit ONLY and is [Ignore]d.
// It references no authoring command or API, because the commands it will drive do not
// exist yet — they land in CLI-191..195. See the plan at:
// docs/authoring/plans/CLI-196-docs-samples-e2e.md
//
// When CLI-191..195 are merged, replace the single Ignored test below with the sequence
// documented here, driving each step through an isolated PipelineTestServer exactly like
// Tests/Editor/Authoring/FolderCommandsTests.cs (the ViaClient pattern):
//
// using (var server = new PipelineTestServer())
// {
// var response = server.Execute("<command>", new { ... });
// Assert.IsTrue(response.IsSuccess, response.Error);
// // pull globalId / assetPath / guid / instanceId / hierarchyPath out of
// // response.JsonResponse["result"] and feed it forward as the next ObjectRef.
// }
//
// Intended end-to-end sequence (dep ticket in brackets; command names are PROVISIONAL —
// confirm against the landed tickets before wiring, do not invent commands):
//
// 0. [CLI-190] set_authoring_root -> "Assets/__CLI196E2E" (reset in TearDown)
// 1. [CLI-190] create_folder -> bare "Work" (resolves to Assets/__CLI196E2E/Work;
// do NOT repeat the root name or it nests) assert IsValidFolder, has guid
// 2. [CLI-191] create_asset / create_material / create_scriptable_object
// -> a leaf asset assert assetPath loads non-null
// 3. [CLI-193] create_scene -> bare "E2E.unity" (-> Assets/__CLI196E2E/E2E.unity) assert exists + active
// 4. [CLI-192] create_gameobject -> /Root, then /Root/Child assert hierarchyPath + instanceId
// 5. [CLI-192] add_component -> e.g. UnityEngine.Rigidbody on a target ObjectRef
// 6. [CLI-194] create_prefab -> bare "Root.prefab" (-> Assets/__CLI196E2E/Root.prefab) from the hierarchy; assert guid
// 7. [CLI-195] create_script (+ recompile + poll recompile_status)
// -> bare "E2EBehaviour.cs" (-> Assets/__CLI196E2E/E2EBehaviour.cs); assert exists/compiles
// 8. [CLI-192/195] add_component -> attach "E2EBehaviour" to the prefab/GameObject
// 9. [CLI-192/195] set_property / set_serialized_reference
// -> wire the behaviour's serialized field to the step-2 asset
// 10. [CLI-193/194] save_scene / save_prefab (or save_asset)
// 11. round-trip verify: re-resolve via globalId/guid (ObjectResolver) and assert the wired
// serialized reference still points at the step-2 asset. THIS is the load-bearing assert:
// it proves the full create -> wire -> save -> reload loop survives serialization.
//
// Suggested fixture shape once unblocked:
// - [SetUp] : set authoring root to the throwaway folder.
// - [TearDown] : ProjectPaths.ResetAuthoringRoot(); delete "Assets/__CLI196E2E";
// close/discard the temporary scene; AssetDatabase.Refresh().
// - mode : EditMode (this assembly). Run via:
// unity command run_tests --mode editor --filter AgentAuthoringE2ETests
// =====================================================================================
/// <summary>
/// End-to-end agent-authoring validation: drives the full create -> build -> wire -> save loop
/// through pipeline commands and asserts the produced assets/scene exist and the serialized
/// reference reads back. Blocked on CLI-191..195 (authoring commands not yet implemented); see
/// docs/authoring/plans/CLI-196-docs-samples-e2e.md.
/// </summary>
public class AgentAuthoringE2ETests
{
[Test]
[Ignore("CLI-196 blocked on CLI-191..195: authoring commands not yet implemented")]
public void FullAuthoringLoop_CreatesAssetsSceneAndPrefab_AndSerializedReferenceReadsBack()
{
// Pending CLI-191..195 — body wired up once the authoring commands land.
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e6b10188957457846bd0037591608c63
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,53 @@
using NUnit.Framework;
using Unity.Pipeline.Editor.Commands;
using UnityEngine;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Tests for the editor_status command (EditorStatusCommand), exercised directly and via
/// PipelineClient.
/// </summary>
public class EditorStatusCommandTests
{
#region Direct
[Test]
public void GetEditorStatus_ReturnsValidData()
{
var status = EditorStatusCommand.GetEditorStatus();
Assert.IsNotNull(status);
Assert.IsNotEmpty(status.Status, "Should report an overall status");
Assert.AreEqual(Application.unityVersion, status.UnityVersion, "Should report the Unity version");
Assert.IsNotEmpty(status.ProjectPath, "Should report the project path");
}
[Test]
public void GetEditorStatus_NotPlaying_ReportsStopped()
{
// This suite runs in EditMode (not play mode).
var status = EditorStatusCommand.GetEditorStatus();
Assert.AreEqual("stopped", status.PlayMode);
}
#endregion
#region ViaClient
[Test]
public void EditorStatus_ViaClient_ReturnsValidJson()
{
using (var server = new PipelineTestServer())
{
var response = server.Execute("editor_status", null);
Assert.IsTrue(response.IsSuccess, $"editor_status should succeed: {response.Error}");
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field");
}
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d447dec5ddcb727469e79314e8082e5a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,40 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using UnityEditor;
using UnityEngine;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Utility methods for Unity Editor testing scenarios.
/// Provides async/await support for Editor state changes and transitions.
/// </summary>
public static class EditorTestUtilities
{
public static async Task WaitFor(Func<bool> condition, int timeoutMs = 1000)
{
var tcs = new TaskCompletionSource<bool>();
var timeoutCts = new CancellationTokenSource(timeoutMs);
timeoutCts.Token.Register(() =>
{
if (!tcs.Task.IsCompleted)
{
tcs.SetResult(false); // Indicate timeout, but don't throw
}
});
while (!condition())
{
await Task.Delay(10); // Small delay to avoid busy waiting
}
}
[MenuItem("PipelineTests/Invoke Modal!")]
public static void InvokeModal()
{
EditorUtility.DisplayDialog("Hello", "world", "okay", "nope");
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3e7304d92634967439572cc8bb57fc88
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,43 @@
using NUnit.Framework;
using Unity.Pipeline.Compilation;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Tests for EvalCodeCompiler's synchronous main-thread compilation/execution.
/// eval is a MainThreadRequired command, so the server marshals to the main thread before
/// invoking; the compiler runs synchronously on the caller (no background/dispatcher path).
/// </summary>
public class EvalCodeCompilerTests
{
[Test]
public void CompileAndExecuteOnMainThread_ReturnsValue()
{
var result = EvalCodeCompiler.CompileAndExecuteOnMainThread("return 42;", 5000, null);
Assert.IsTrue(result.Success, $"Should succeed. Error: {result.Error}");
Assert.AreEqual(42, result.Result, "Should return the evaluated value");
}
[Test]
public void CompileAndExecuteOnMainThread_AccessesUnityApi()
{
var result = EvalCodeCompiler.CompileAndExecuteOnMainThread("return Application.unityVersion;", 5000, null);
Assert.IsTrue(result.Success, $"Unity API access should work. Error: {result.Error}");
Assert.IsInstanceOf<string>(result.Result, "Unity version should be a string");
Assert.IsNotEmpty((string)result.Result, "Unity version should not be empty");
}
[Test]
public void CompileAndExecuteOnMainThread_CompilationError_ReturnsDiagnostics()
{
var result = EvalCodeCompiler.CompileAndExecuteOnMainThread("return 2 +;", 5000, null);
Assert.IsFalse(result.Success, "Invalid syntax should fail");
Assert.AreEqual("Compilation Failed", result.Error, "Should report a compilation error");
Assert.IsNotNull(result.Diagnostics, "Should have diagnostics");
Assert.Greater(result.Diagnostics.Count, 0, "Should include compilation diagnostics");
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1e0da069dfce7c846abb7baa50413132
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,87 @@
using NUnit.Framework;
using Unity.Pipeline.Runtime.Telemetry;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Unit tests for <see cref="FrameStatsSampler"/>'s windowing math. These run in EditMode without a
/// Player or RuntimePipelineManager — the sampler is a plain class fed synthetic frame times.
/// </summary>
public class FrameStatsSamplerTests
{
private const float Tolerance = 0.001f;
[Test]
public void NewSampler_HasNoSamples_SnapshotUnavailable()
{
using (var sampler = new FrameStatsSampler())
{
var snap = sampler.GetSnapshot();
Assert.IsFalse(snap.Available, "a sampler with no frames must report unavailable");
Assert.AreEqual(0, snap.SampleWindow);
Assert.IsNotNull(snap.Counters, "counters map should never be null");
}
}
[Test]
public void Sample_ComputesAverageMinMaxAndFps()
{
using (var sampler = new FrameStatsSampler())
{
// 10ms, 20ms, 30ms -> avg 20ms -> 50 fps.
sampler.Sample(0.010f);
sampler.Sample(0.020f);
sampler.Sample(0.030f);
var snap = sampler.GetSnapshot();
Assert.IsTrue(snap.Available);
Assert.AreEqual(3, snap.SampleWindow);
Assert.AreEqual(20f, snap.AverageFrameTimeMs, Tolerance);
Assert.AreEqual(10f, snap.MinFrameTimeMs, Tolerance);
Assert.AreEqual(30f, snap.MaxFrameTimeMs, Tolerance);
Assert.AreEqual(30f, snap.LastFrameTimeMs, Tolerance);
Assert.AreEqual(50f, snap.Fps, 0.01f);
}
}
[Test]
public void Sample_BeyondCapacity_KeepsRollingWindow()
{
using (var sampler = new FrameStatsSampler(windowFrames: 3))
{
Assert.AreEqual(3, sampler.Capacity);
// Feed 5 frames into a 3-frame window; only the last three (30/40/50ms) should survive.
sampler.Sample(0.010f);
sampler.Sample(0.020f);
sampler.Sample(0.030f);
sampler.Sample(0.040f);
sampler.Sample(0.050f);
var snap = sampler.GetSnapshot();
Assert.AreEqual(3, snap.SampleWindow, "window must not exceed capacity");
Assert.AreEqual(40f, snap.AverageFrameTimeMs, Tolerance);
Assert.AreEqual(30f, snap.MinFrameTimeMs, Tolerance);
Assert.AreEqual(50f, snap.MaxFrameTimeMs, Tolerance);
Assert.AreEqual(50f, snap.LastFrameTimeMs, Tolerance);
}
}
[Test]
public void Sample_ZeroDelta_DoesNotDivideByZeroFps()
{
using (var sampler = new FrameStatsSampler())
{
sampler.Sample(0f);
var snap = sampler.GetSnapshot();
Assert.IsTrue(snap.Available);
Assert.AreEqual(0f, snap.Fps, Tolerance, "zero average frame time must yield 0 fps, not Infinity/NaN");
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7f0d8ca052ad4db2baf439859a396d07
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: cfbbfdc51a7141dc947b6d4a3791cd67
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,377 @@
using System.Collections.Generic;
using NUnit.Framework;
using Newtonsoft.Json.Linq;
using Unity.Pipeline.Editor.Commands.GameObjects;
using Unity.Pipeline.Models;
using UnityEditor;
using UnityEngine;
using Object = UnityEngine.Object;
using Unity.Pipeline;
namespace Unity.Pipeline.Tests.Editor.GameObjects
{
/// <summary>
/// Tests for the shared string-encoded JSON parameter coercion (CLI-219 / CLI-220) and the batch
/// <c>create_gameobjects</c> command (CLI-223).
///
/// CLI-219/220 are a single root cause: when an agent/CLI passes a structured parameter as a
/// JSON-ENCODED STRING (position "[1,2,3]", properties "{\"m_Mass\":0.17}"), the server must
/// re-parse it before converting. The ViaClient tests below pass those forms as real strings to
/// prove the coercion; the Direct tests pass real typed values to prove the typed path still works.
/// The guard is also pinned: a plain string param and an ObjectRef string handle ("/Name") must NOT
/// be re-parsed.
/// </summary>
public class CoercionAndBatchTests
{
private readonly List<GameObject> m_Spawned = new List<GameObject>();
private GameObject Track(GameObject go)
{
m_Spawned.Add(go);
return go;
}
private static ObjectRef RefTo(Object obj) => new ObjectRef { InstanceId = PipelineUtils.GetObjectId(obj) };
[TearDown]
public void TearDown()
{
foreach (var go in m_Spawned)
{
if (go != null)
Object.DestroyImmediate(go);
}
m_Spawned.Clear();
}
#region CLI-219 set_transform
[Test]
public void SetTransform_Direct_TypedArrays_Apply()
{
var go = Track(new GameObject("Xform219_Direct"));
GameObjectCommands.SetTransform(RefTo(go),
position: new[] { 1f, 2f, 3f },
rotation: new[] { 0f, 90f, 0f },
scale: new[] { 2f, 2f, 2f });
Assert.AreEqual(new Vector3(1, 2, 3), go.transform.localPosition);
Assert.AreEqual(new Vector3(2, 2, 2), go.transform.localScale);
Assert.AreEqual(90f, go.transform.localEulerAngles.y, 0.01f);
}
[Test]
public void SetTransform_ViaClient_ArrayParams_Apply()
{
var go = Track(new GameObject("Xform219_Arrays"));
using (var server = new PipelineTestServer())
{
var response = server.Execute("set_transform", new
{
target = new { instanceId = PipelineUtils.GetObjectId(go) },
position = new[] { 1f, 2f, 3f },
rotation = new[] { 0f, 90f, 0f },
scale = new[] { 2f, 2f, 2f }
});
Assert.IsTrue(response.IsSuccess, $"set_transform should succeed: {response.Error}");
Assert.AreEqual(new Vector3(1, 2, 3), go.transform.localPosition);
Assert.AreEqual(new Vector3(2, 2, 2), go.transform.localScale);
Assert.AreEqual(90f, go.transform.localEulerAngles.y, 0.01f);
}
}
[Test]
public void SetTransform_ViaClient_JsonStringParams_Coerced()
{
// The CLI-219 repro: position/rotation/scale arrive as JSON-encoded STRINGS. Before the
// coercion these convert to null (JValue(string).ToObject(float[]) == null) and the command
// silently applies nothing. They must now be re-parsed and applied.
var go = Track(new GameObject("Xform219_Strings"));
using (var server = new PipelineTestServer())
{
var response = server.Execute("set_transform", new
{
target = new { instanceId = PipelineUtils.GetObjectId(go) },
position = "[1,2,3]",
rotation = "[0,90,0]",
scale = "[2,2,2]"
});
Assert.IsTrue(response.IsSuccess, $"set_transform should succeed: {response.Error}");
Assert.AreEqual(new Vector3(1, 2, 3), go.transform.localPosition,
"String-encoded position must be coerced and applied");
Assert.AreEqual(new Vector3(2, 2, 2), go.transform.localScale,
"String-encoded scale must be coerced and applied");
Assert.AreEqual(90f, go.transform.localEulerAngles.y, 0.01f,
"String-encoded rotation must be coerced and applied");
}
}
[Test]
public void SetTransform_ViaClient_NestedTarget_StringParams_Coerced()
{
// Cover a parented/nested target too: the handle resolves the child, and the string-encoded
// position applies as a LOCAL transform on the child.
var parent = Track(new GameObject("Xform219_Parent"));
var child = Track(new GameObject("Xform219_Child"));
child.transform.SetParent(parent.transform);
using (var server = new PipelineTestServer())
{
var response = server.Execute("set_transform", new
{
target = new { instanceId = PipelineUtils.GetObjectId(child) },
position = "[4,5,6]"
});
Assert.IsTrue(response.IsSuccess, $"set_transform should succeed: {response.Error}");
Assert.AreEqual(parent.transform, child.transform.parent, "Child stays parented");
Assert.AreEqual(new Vector3(4, 5, 6), child.transform.localPosition,
"String-encoded local position must apply to the nested child");
}
}
#endregion
#region CLI-220 set_component_properties
[Test]
public void SetComponentProperties_Direct_JObject_Applies()
{
var go = Track(new GameObject("Props220_Direct"));
var rb = go.AddComponent<Rigidbody>();
ComponentCommands.SetComponentProperties(RefTo(rb), new JObject { ["m_Mass"] = 0.17f });
Assert.AreEqual(0.17f, rb.mass, 0.0001f, "Real JObject properties must still apply");
}
[Test]
public void SetComponentProperties_ViaClient_PropertiesAsJsonString_Coerced()
{
// The CLI-220 repro: `properties` arrives as a JSON-encoded STRING. Before the coercion it
// converts to null and the command fails "Required parameter 'properties' is missing or
// empty". It must now be re-parsed into a JObject and applied.
var go = Track(new GameObject("Props220_String"));
var rb = go.AddComponent<Rigidbody>();
using (var server = new PipelineTestServer())
{
var response = server.Execute("set_component_properties", new
{
target = new { instanceId = PipelineUtils.GetObjectId(rb) },
type = "Rigidbody",
properties = "{\"m_Mass\":0.17}"
});
Assert.IsTrue(response.IsSuccess,
$"set_component_properties should succeed with a string-encoded properties: {response.Error}");
Assert.AreEqual(0.17f, rb.mass, 0.0001f,
"String-encoded properties must be coerced and applied");
}
}
#endregion
#region CLI-223 create_gameobjects (batch)
[Test]
public void CreateGameObjects_Direct_CountOne_ReturnsSingleIdentity()
{
// Regression: count=1 keeps the bare name (no "1" suffix) and returns exactly one item.
var result = GameObjectCommands.CreateGameObjects(name: "Batch223_One", primitive: "cube", count: 1);
Assert.AreEqual(1, result.Count);
Assert.AreEqual(1, result.GameObjects.Count);
var go = PipelineUtils.IdToObject(result.GameObjects[0].InstanceId.Value) as GameObject;
Track(go);
Assert.IsNotNull(go, "Created object should be resolvable by instanceId");
Assert.AreEqual("Batch223_One", go.name, "count=1 must not append a numeric suffix");
Assert.IsNotNull(go.GetComponent<BoxCollider>(), "Cube primitive should have a BoxCollider");
}
[Test]
public void CreateGameObjects_Direct_CountN_WithPositions_AppliesEach()
{
var positions = new[]
{
new[] { 0f, 0f, 0f },
new[] { 1f, 0f, 0f },
new[] { 2f, 0f, 0f },
};
var result = GameObjectCommands.CreateGameObjects(
name: "Batch223_Pos", primitive: "sphere", count: 3, positions: positions);
Assert.AreEqual(3, result.Count);
for (int i = 0; i < 3; i++)
{
var go = PipelineUtils.IdToObject(result.GameObjects[i].InstanceId.Value) as GameObject;
Track(go);
Assert.AreEqual("Batch223_Pos" + (i + 1), go.name, "count>1 must suffix Name1..NameN");
Assert.AreEqual(new Vector3(i, 0, 0), go.transform.localPosition,
"Each object must take its per-index position");
}
}
[Test]
public void CreateGameObjects_Direct_CountN_NoPositions_StackedAtOrigin()
{
var result = GameObjectCommands.CreateGameObjects(name: "Batch223_Origin", count: 4);
Assert.AreEqual(4, result.Count);
foreach (var item in result.GameObjects)
{
var go = PipelineUtils.IdToObject(item.InstanceId.Value) as GameObject;
Track(go);
Assert.AreEqual(Vector3.zero, go.transform.localPosition,
"With no positions array, all objects stay at the origin");
}
}
[Test]
public void CreateGameObjects_Direct_MismatchedPositionsLength_Throws()
{
// 2 vectors for count=3 is an agent error and must reject before creating anything.
var positions = new[]
{
new[] { 0f, 0f, 0f },
new[] { 1f, 0f, 0f },
};
Assert.Throws<System.ArgumentException>(() =>
GameObjectCommands.CreateGameObjects(name: "Batch223_Bad", count: 3, positions: positions));
}
[Test]
public void CreateGameObjects_Direct_NullPositionEntry_ThrowsWithIndex()
{
// A positions array whose LENGTH matches count but contains a null entry must fail with a
// clear, index-bearing ArgumentException (not a NullReferenceException), up front, before any
// object is created.
var positions = new[]
{
new[] { 0f, 0f, 0f },
null,
new[] { 2f, 0f, 0f },
};
var ex = Assert.Throws<System.ArgumentException>(() =>
GameObjectCommands.CreateGameObjects(name: "Batch223_NullEntry", count: 3, positions: positions));
StringAssert.Contains("positions[1]", ex.Message, "The error should name the offending index");
// Validation runs before creation, so nothing should have been spawned.
var leaked = GameObjectCommands.FindGameObjects(name: "Batch223_NullEntry1");
Assert.AreEqual(0, leaked.Count, "A rejected batch must create nothing");
}
[Test]
public void CreateGameObjects_ViaClient_CountN_WithStringEncodedPositions()
{
// End-to-end: float[][] positions arrive as a JSON STRING (the CLI-219/220 coercion also
// re-parses string-encoded nested arrays), and each object takes its per-index position.
using (var server = new PipelineTestServer())
{
var response = server.Execute("create_gameobjects", new
{
name = "Batch223_Via",
primitive = "cube",
count = 3,
positions = "[[0,0,0],[1,0,0],[2,0,0]]"
});
Assert.IsTrue(response.IsSuccess, $"create_gameobjects should succeed: {response.Error}");
var count = response.JsonResponse["result"]?["count"]?.ToObject<int>();
Assert.AreEqual(3, count, "Batch should report 3 created");
var gameObjects = response.JsonResponse["result"]?["gameObjects"] as JArray;
Assert.IsNotNull(gameObjects, "Result should carry a gameObjects array");
Assert.AreEqual(3, gameObjects.Count);
for (int i = 0; i < 3; i++)
{
var instanceId = gameObjects[i]?["instanceId"]?.ToObject<ObjectId?>();
Assert.IsTrue(instanceId.HasValue, "Each created object should carry an instanceId");
var go = PipelineUtils.IdToObject(instanceId.Value) as GameObject;
Track(go);
Assert.IsNotNull(go, "Created object should be resolvable");
Assert.AreEqual(new Vector3(i, 0, 0), go.transform.localPosition,
"Each object must take its per-index position (string-encoded array coerced)");
}
}
}
[Test]
public void CreateGameObjects_ViaClient_CountOne_Regression()
{
using (var server = new PipelineTestServer())
{
var response = server.Execute("create_gameobjects", new { name = "Batch223_ViaOne", count = 1 });
Assert.IsTrue(response.IsSuccess, $"create_gameobjects should succeed: {response.Error}");
var count = response.JsonResponse["result"]?["count"]?.ToObject<int>();
Assert.AreEqual(1, count);
var instanceId = response.JsonResponse["result"]?["gameObjects"]?[0]?["instanceId"]?.ToObject<ObjectId?>();
Assert.IsTrue(instanceId.HasValue);
var go = PipelineUtils.IdToObject(instanceId.Value) as GameObject;
Track(go);
Assert.AreEqual("Batch223_ViaOne", go.name, "count=1 must not append a numeric suffix");
}
}
#endregion
#region Coercion guard (does not break plain strings / ObjectRef handles)
[Test]
public void Coercion_PlainStringParam_NotReparsed()
{
// rename_gameobject takes a plain string 'name'. A normal string must pass straight through
// (it does not start with '{' or '[', so the guard never touches it).
var go = Track(new GameObject("Guard_Old"));
using (var server = new PipelineTestServer())
{
var response = server.Execute("rename_gameobject", new
{
target = new { instanceId = PipelineUtils.GetObjectId(go) },
name = "Guard_New"
});
Assert.IsTrue(response.IsSuccess, $"rename_gameobject should succeed: {response.Error}");
Assert.AreEqual("Guard_New", go.name, "Plain string param must apply unchanged");
}
}
[Test]
public void Coercion_ObjectRefStringHandle_StillResolves()
{
// An ObjectRef passed as a bare string handle ("/Name") must continue flowing to
// ObjectRefConverter — it does not start with '{' or '[', so the coercion leaves it alone.
var go = Track(new GameObject("Guard_HandleTarget"));
using (var server = new PipelineTestServer())
{
var response = server.Execute("set_active", new
{
target = "/Guard_HandleTarget",
active = false
});
Assert.IsTrue(response.IsSuccess,
$"set_active with a string handle should succeed: {response.Error}");
Assert.IsFalse(go.activeSelf, "ObjectRef string handle must still resolve and apply");
}
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: be72bf79d228f86448e2d3fc5ed00c17
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,400 @@
using System.Collections.Generic;
using NUnit.Framework;
using Newtonsoft.Json.Linq;
using Unity.Pipeline.Editor.Commands.GameObjects;
using Unity.Pipeline.Models;
using UnityEditor;
using UnityEngine;
using Object = UnityEngine.Object;
using Unity.Pipeline;
namespace Unity.Pipeline.Tests.Editor.GameObjects
{
/// <summary>
/// Tests for the component authoring commands (CLI-192), exercised directly and via
/// <see cref="PipelineTestServer"/>. Covers add/remove, a serialized-property round trip
/// (set -> get) over primitive + enum + Vector types, object-reference assignment via a resolved
/// handle, and the Undo-revert contract for a property mutation.
/// </summary>
public class ComponentCommandsTests
{
private readonly List<GameObject> m_Spawned = new List<GameObject>();
private GameObject Track(GameObject go)
{
m_Spawned.Add(go);
return go;
}
private static ObjectRef RefTo(Object obj) => new ObjectRef { InstanceId = PipelineUtils.GetObjectId(obj) };
// Temp asset folder for the CLI-220 follow-up tests (asset references + arrays); cleaned in TearDown.
private const string AssetRoot = "Assets/__CLI220Test";
[TearDown]
public void TearDown()
{
foreach (var go in m_Spawned)
{
if (go != null)
Object.DestroyImmediate(go);
}
m_Spawned.Clear();
if (AssetDatabase.IsValidFolder(AssetRoot))
{
AssetDatabase.DeleteAsset(AssetRoot);
AssetDatabase.Refresh();
}
}
private static void EnsureAssetRoot()
{
if (!AssetDatabase.IsValidFolder(AssetRoot))
AssetDatabase.CreateFolder("Assets", "__CLI220Test");
}
/// <summary>Pick a shader that resolves on this project's active pipeline.</summary>
private static Shader KnownShader()
{
return Shader.Find("Standard")
?? Shader.Find("Universal Render Pipeline/Lit")
?? Shader.Find("Sprites/Default");
}
/// <summary>Create a temporary Material asset under <see cref="AssetRoot"/> and return the loaded asset.</summary>
private static Material CreateTempMaterial(string name)
{
EnsureAssetRoot();
var mat = new Material(KnownShader()) { name = name };
var path = $"{AssetRoot}/{name}.mat";
AssetDatabase.CreateAsset(mat, path);
AssetDatabase.SaveAssets();
return AssetDatabase.LoadAssetAtPath<Material>(path);
}
/// <summary>Create a temporary Mesh asset (a shaderless, version-stable scalar object reference).</summary>
private static Mesh CreateTempMesh(string name)
{
EnsureAssetRoot();
var mesh = new Mesh { name = name };
var path = $"{AssetRoot}/{name}.asset";
AssetDatabase.CreateAsset(mesh, path);
AssetDatabase.SaveAssets();
return AssetDatabase.LoadAssetAtPath<Mesh>(path);
}
#region Direct
[Test]
public void AddComponent_ByShortName_Adds()
{
var go = Track(new GameObject("AddComp_CLI192"));
var result = ComponentCommands.AddComponent(RefTo(go), "Rigidbody");
Assert.IsNotNull(go.GetComponent<Rigidbody>(), "Rigidbody should be added");
Assert.AreEqual("Rigidbody", result.Type);
}
[Test]
public void AddComponent_ByFullyQualifiedName_Adds()
{
var go = Track(new GameObject("AddCompFq_CLI192"));
ComponentCommands.AddComponent(RefTo(go), "UnityEngine.Rigidbody");
Assert.IsNotNull(go.GetComponent<Rigidbody>());
}
[Test]
public void RemoveComponent_ByGameObjectAndType_Removes()
{
var go = Track(new GameObject("RemoveComp_CLI192"));
go.AddComponent<Rigidbody>();
ComponentCommands.RemoveComponent(RefTo(go), "Rigidbody");
Assert.IsNull(go.GetComponent<Rigidbody>(), "Rigidbody should be removed");
}
[Test]
public void SetGetProperties_PrimitiveRoundTrip()
{
var go = Track(new GameObject("PropPrim_CLI192"));
var rb = go.AddComponent<Rigidbody>();
ComponentCommands.SetComponentProperties(RefTo(rb),
new JObject { ["m_Mass"] = 12.5f });
Assert.AreEqual(12.5f, rb.mass, 0.001f, "Mass should be set on the live component");
var read = ComponentCommands.GetComponentProperties(RefTo(rb));
Assert.AreEqual(12.5, read.Properties["m_Mass"].ToObject<double>(), 0.001,
"get should read back the value that set wrote");
}
[Test]
public void SetProperties_EnumByName_Applies()
{
var go = Track(new GameObject("PropEnum_CLI192"));
var rb = go.AddComponent<Rigidbody>();
// m_CollisionDetection maps to the CollisionDetectionMode enum.
ComponentCommands.SetComponentProperties(RefTo(rb),
new JObject { ["m_CollisionDetection"] = "Continuous" });
Assert.AreEqual(CollisionDetectionMode.Continuous, rb.collisionDetectionMode,
"Enum set by name should apply");
}
[Test]
public void SetProperties_VectorType_Applies()
{
var go = Track(new GameObject("PropVec_CLI192"));
var box = go.AddComponent<BoxCollider>();
ComponentCommands.SetComponentProperties(RefTo(box),
new JObject { ["m_Size"] = new JArray(2f, 3f, 4f) });
Assert.AreEqual(new Vector3(2, 3, 4), box.size, "Vector3 property should be set from a JSON array");
}
[Test]
public void SetProperties_ObjectReference_ByResolvedHandle()
{
// Create a real Mesh asset on disk so it resolves through ObjectResolver by path. A Mesh is
// a version-stable, single object-reference target (MeshFilter.m_Mesh).
const string dir = "Assets/__CLI192ObjRefTest";
if (!AssetDatabase.IsValidFolder(dir))
AssetDatabase.CreateFolder("Assets", "__CLI192ObjRefTest");
var meshPath = dir + "/RefMesh.asset";
var mesh = new Mesh { name = "RefMesh_CLI192" };
AssetDatabase.CreateAsset(mesh, meshPath);
AssetDatabase.SaveAssets();
try
{
var go = Track(new GameObject("PropObjRef_CLI192"));
var mf = go.AddComponent<MeshFilter>();
// MeshFilter.m_Mesh is a single Mesh object reference.
ComponentCommands.SetComponentProperties(RefTo(mf),
new JObject { ["m_Mesh"] = new JObject { ["path"] = meshPath } });
Assert.IsNotNull(mf.sharedMesh, "Object reference should be assigned from the resolved handle");
Assert.AreEqual(mesh, mf.sharedMesh, "Should be the exact resolved asset");
}
finally
{
AssetDatabase.DeleteAsset(dir);
AssetDatabase.Refresh();
}
}
[Test]
public void SetProperties_UnknownProperty_Throws()
{
var go = Track(new GameObject("PropBad_CLI192"));
var rb = go.AddComponent<Rigidbody>();
Assert.Throws<System.ArgumentException>(() =>
ComponentCommands.SetComponentProperties(RefTo(rb),
new JObject { ["m_NoSuchProperty"] = 1 }));
}
[Test]
public void SetProperties_IsUndone()
{
var go = Track(new GameObject("PropUndo_CLI192"));
var rb = go.AddComponent<Rigidbody>();
rb.mass = 1f;
ComponentCommands.SetComponentProperties(RefTo(rb), new JObject { ["m_Mass"] = 9f });
Assert.AreEqual(9f, rb.mass, 0.001f);
Undo.PerformUndo();
Assert.AreEqual(1f, rb.mass, 0.001f, "Undo should revert the property change");
}
#endregion
#region ViaClient
[Test]
public void AddComponent_ViaClient_Succeeds()
{
var go = Track(new GameObject("ViaAdd_CLI192"));
using (var server = new PipelineTestServer())
{
var response = server.Execute("add_component",
new { target = new { instanceId = PipelineUtils.GetObjectId(go) }, type = "Rigidbody" });
Assert.IsTrue(response.IsSuccess, $"add_component should succeed: {response.Error}");
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
Assert.IsNotNull(go.GetComponent<Rigidbody>(), "Rigidbody should be added via client");
}
}
[Test]
public void SetComponentProperties_ViaClient_RoundTrips()
{
var go = Track(new GameObject("ViaProp_CLI192"));
var rb = go.AddComponent<Rigidbody>();
using (var server = new PipelineTestServer())
{
var response = server.Execute("set_component_properties", new
{
target = new { instanceId = PipelineUtils.GetObjectId(rb) },
properties = new { m_Mass = 7.0 }
});
Assert.IsTrue(response.IsSuccess, $"set_component_properties should succeed: {response.Error}");
Assert.AreEqual(7f, rb.mass, 0.001f, "Mass should be set via client");
var massBack = response.JsonResponse["result"]?["properties"]?["m_Mass"]?.ToObject<double>();
Assert.AreEqual(7.0, massBack.Value, 0.001, "Result should echo the committed value");
}
}
#endregion
#region CLI-220 follow-ups asset references + array properties
[Test]
public void SetProperties_AssetReference_ByInstanceId_Assigns()
{
// The core of the bug: an ASSET reference addressed by instanceId must actually resolve and
// assign (it used to silently no-op and report success).
var mesh = CreateTempMesh("ByInstanceId");
var go = Track(new GameObject("PropMeshInst_CLI220"));
var mf = go.AddComponent<MeshFilter>();
ComponentCommands.SetComponentProperties(RefTo(mf),
new JObject { ["m_Mesh"] = new JObject { ["instanceId"] = PipelineUtils.GetObjectId(mesh).RawValue } });
Assert.AreEqual(mesh, mf.sharedMesh, "Asset reference by instanceId should be assigned");
}
[Test]
public void SetProperties_AssetReference_ByGuid_Assigns()
{
var mesh = CreateTempMesh("ByGuid");
AssetDatabase.TryGetGUIDAndLocalFileIdentifier(mesh, out var guid, out long _);
var go = Track(new GameObject("PropMeshGuid_CLI220"));
var mf = go.AddComponent<MeshFilter>();
ComponentCommands.SetComponentProperties(RefTo(mf),
new JObject { ["m_Mesh"] = new JObject { ["guid"] = guid } });
Assert.AreEqual(mesh, mf.sharedMesh, "Asset reference by guid should be assigned");
}
[Test]
public void SetProperties_UnresolvableReference_Throws()
{
// Previously this returned success while assigning nothing. It must now fail loudly.
var go = Track(new GameObject("PropBadRef_CLI220"));
var mf = go.AddComponent<MeshFilter>();
Assert.Throws<System.ArgumentException>(() =>
ComponentCommands.SetComponentProperties(RefTo(mf),
new JObject { ["m_Mesh"] = new JObject { ["instanceId"] = 999999999 } }),
"An unresolvable object reference must throw, not silently succeed.");
}
[Test]
public void SetProperties_ArrayOfObjectReferences_Assigns()
{
var matA = CreateTempMaterial("ArrA");
var matB = CreateTempMaterial("ArrB");
var go = Track(new GameObject("PropMatArray_CLI220"));
var renderer = go.AddComponent<MeshRenderer>();
// MeshRenderer.m_Materials is an object-reference array (Generic+isArray).
ComponentCommands.SetComponentProperties(RefTo(renderer), new JObject
{
["m_Materials"] = new JArray(
new JObject { ["path"] = AssetDatabase.GetAssetPath(matA) },
new JObject { ["path"] = AssetDatabase.GetAssetPath(matB) })
});
Assert.AreEqual(2, renderer.sharedMaterials.Length, "Array property should be sized to the JSON array");
Assert.AreEqual(matA, renderer.sharedMaterials[0]);
Assert.AreEqual(matB, renderer.sharedMaterials[1]);
}
[Test]
public void GetProperties_Array_ReadsBack()
{
var matA = CreateTempMaterial("ReadA");
var matB = CreateTempMaterial("ReadB");
var go = Track(new GameObject("PropMatArrayRead_CLI220"));
var renderer = go.AddComponent<MeshRenderer>();
renderer.sharedMaterials = new[] { matA, matB };
var read = ComponentCommands.GetComponentProperties(RefTo(renderer));
var materials = read.Properties["m_Materials"] as JArray;
Assert.IsNotNull(materials, "Array property should read back as a JSON array");
Assert.AreEqual(2, materials.Count, "Array read should report both elements");
}
[Test]
public void SetProperties_ViaClient_AssetByCapitalInstanceID_Assigns()
{
// The agent's exact repro: a handle with a capital-D "instanceID" key. It must bind
// (case-insensitive) and assign the asset over the wire.
var mesh = CreateTempMesh("ViaCapital");
var go = Track(new GameObject("PropMeshCap_CLI220"));
var mf = go.AddComponent<MeshFilter>();
using (var server = new PipelineTestServer())
{
var response = server.Execute("set_component_properties", new
{
target = new { instanceId = PipelineUtils.GetObjectId(mf) },
properties = new { m_Mesh = new { instanceID = PipelineUtils.GetObjectId(mesh) } }
});
Assert.IsTrue(response.IsSuccess, $"set_component_properties should succeed: {response.Error}");
Assert.AreEqual(mesh, mf.sharedMesh, "Capital 'instanceID' handle must resolve and assign the asset");
}
}
[Test]
public void SetProperties_ViaClient_MaterialArray_Assigns()
{
var matA = CreateTempMaterial("ViaArrA");
var matB = CreateTempMaterial("ViaArrB");
var go = Track(new GameObject("PropMatArrayVia_CLI220"));
var renderer = go.AddComponent<MeshRenderer>();
using (var server = new PipelineTestServer())
{
var response = server.Execute("set_component_properties", new
{
target = new { instanceId = PipelineUtils.GetObjectId(renderer) },
properties = new
{
m_Materials = new object[]
{
new { path = AssetDatabase.GetAssetPath(matA) },
new { path = AssetDatabase.GetAssetPath(matB) }
}
}
});
Assert.IsTrue(response.IsSuccess, $"set_component_properties (array) should succeed: {response.Error}");
Assert.AreEqual(2, renderer.sharedMaterials.Length, "Material array should be set via client");
Assert.AreEqual(matA, renderer.sharedMaterials[0]);
Assert.AreEqual(matB, renderer.sharedMaterials[1]);
}
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cfcba8e8d69748c7a4facfd2b2ecff17
@@ -0,0 +1,217 @@
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using Unity.Pipeline.Editor.Commands.GameObjects;
using Unity.Pipeline.Models;
using UnityEditor;
using UnityEngine;
using Object = UnityEngine.Object;
using Unity.Pipeline;
namespace Unity.Pipeline.Tests.Editor.GameObjects
{
/// <summary>
/// Tests for the GameObject authoring commands (CLI-192), exercised directly and via
/// <see cref="PipelineTestServer"/>. Covers creation (empty + primitive), hierarchy build via
/// set_parent, the find query, transform/active/tag/layer/rename mutations, deletion, and the
/// Undo-revert contract. Test GameObjects are created with plain UnityEngine/UnityEditor APIs and
/// torn down explicitly so the suite leaves no scene residue.
/// </summary>
public class GameObjectCommandsTests
{
private readonly List<GameObject> m_Spawned = new List<GameObject>();
private GameObject Track(GameObject go)
{
m_Spawned.Add(go);
return go;
}
private static ObjectRef RefTo(Object obj) => new ObjectRef { InstanceId = PipelineUtils.GetObjectId(obj) };
[TearDown]
public void TearDown()
{
foreach (var go in m_Spawned)
{
if (go != null)
Object.DestroyImmediate(go);
}
m_Spawned.Clear();
}
#region Direct
[Test]
public void CreateGameObject_Empty_ReturnsIdentity()
{
var result = GameObjectCommands.CreateGameObject("Empty_CLI192");
var go = PipelineUtils.IdToObject(result.InstanceId.Value) as GameObject;
Track(go);
Assert.IsNotNull(go, "Created object should be resolvable by instanceId");
Assert.AreEqual("Empty_CLI192", go.name);
Assert.IsNull(go.GetComponent<MeshFilter>(), "Empty GO has no mesh");
Assert.IsNotNull(result.HierarchyPath, "Scene object should report a hierarchy path");
}
[Test]
public void CreateGameObject_Primitive_AttachesMeshAndCollider()
{
var result = GameObjectCommands.CreateGameObject("Cube_CLI192", "cube");
var go = PipelineUtils.IdToObject(result.InstanceId.Value) as GameObject;
Track(go);
Assert.IsNotNull(go.GetComponent<MeshFilter>(), "Primitive cube should have a MeshFilter");
Assert.IsNotNull(go.GetComponent<BoxCollider>(), "Primitive cube should have a BoxCollider");
}
[Test]
public void CreateGameObject_WithParent_BuildsHierarchy()
{
var parent = Track(new GameObject("Parent_CLI192"));
var childResult = GameObjectCommands.CreateGameObject("Child_CLI192", null, RefTo(parent));
var child = PipelineUtils.IdToObject(childResult.InstanceId.Value) as GameObject;
Assert.AreEqual(parent.transform, child.transform.parent, "Child should be parented");
}
[Test]
public void SetTransform_PrimitiveChannels_Apply()
{
var go = Track(new GameObject("Xform_CLI192"));
GameObjectCommands.SetTransform(RefTo(go),
position: new[] { 1f, 2f, 3f },
rotation: new[] { 0f, 90f, 0f },
scale: new[] { 2f, 2f, 2f });
Assert.AreEqual(new Vector3(1, 2, 3), go.transform.localPosition);
Assert.AreEqual(new Vector3(2, 2, 2), go.transform.localScale);
Assert.AreEqual(90f, go.transform.localEulerAngles.y, 0.01f);
}
[Test]
public void SetParent_ToNull_DetachesToRoot()
{
var parent = Track(new GameObject("P_CLI192"));
var child = Track(new GameObject("C_CLI192"));
child.transform.SetParent(parent.transform);
GameObjectCommands.SetParent(RefTo(child), null);
Assert.IsNull(child.transform.parent, "Child should be detached to scene root");
}
[Test]
public void SetActive_TogglesState()
{
var go = Track(new GameObject("Active_CLI192"));
GameObjectCommands.SetActive(RefTo(go), false);
Assert.IsFalse(go.activeSelf);
GameObjectCommands.SetActive(RefTo(go), true);
Assert.IsTrue(go.activeSelf);
}
[Test]
public void SetLayer_ByIndex_Applies()
{
var go = Track(new GameObject("Layer_CLI192"));
GameObjectCommands.SetLayer(RefTo(go), "5"); // UI layer index
Assert.AreEqual(5, go.layer);
}
[Test]
public void SetTag_UnknownTag_Throws()
{
var go = Track(new GameObject("Tag_CLI192"));
Assert.Throws<System.ArgumentException>(
() => GameObjectCommands.SetTag(RefTo(go), "__definitely_not_a_real_tag__"));
}
[Test]
public void Rename_ChangesName()
{
var go = Track(new GameObject("Old_CLI192"));
GameObjectCommands.RenameGameObject(RefTo(go), "New_CLI192");
Assert.AreEqual("New_CLI192", go.name);
}
[Test]
public void FindGameObjects_ByNameAndType_Filters()
{
var go = Track(new GameObject("Findable_CLI192"));
go.AddComponent<Rigidbody>();
var result = GameObjectCommands.FindGameObjects(name: "Findable_CLI192", type: "Rigidbody");
Assert.AreEqual(1, result.Count);
Assert.AreEqual(PipelineUtils.GetObjectId(go), result.GameObjects[0].InstanceId);
}
[Test]
public void Delete_RemovesFromScene()
{
var go = new GameObject("Doomed_CLI192");
var id = PipelineUtils.GetObjectId(go);
GameObjectCommands.DeleteGameObject(new ObjectRef { InstanceId = id });
Assert.IsNull(PipelineUtils.IdToObject(id), "GameObject should be destroyed");
}
[Test]
public void SetTransform_IsUndone()
{
var go = Track(new GameObject("Undo_CLI192"));
go.transform.localPosition = Vector3.zero;
GameObjectCommands.SetTransform(RefTo(go), position: new[] { 5f, 5f, 5f });
Assert.AreEqual(new Vector3(5, 5, 5), go.transform.localPosition);
Undo.PerformUndo();
Assert.AreEqual(Vector3.zero, go.transform.localPosition, "Undo should revert the transform change");
}
#endregion
#region ViaClient
[Test]
public void CreateGameObject_ViaClient_Succeeds()
{
using (var server = new PipelineTestServer())
{
var response = server.Execute("create_gameobject", new { name = "ViaClient_CLI192", primitive = "sphere" });
Assert.IsTrue(response.IsSuccess, $"create_gameobject should succeed: {response.Error}");
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
Assert.IsTrue(response.JsonResponse.ContainsKey("result"), "Should have result field");
var instanceId = response.JsonResponse["result"]?["instanceId"]?.ToObject<ObjectId?>();
Assert.IsTrue(instanceId.HasValue, "Result should carry an instanceId");
var go = PipelineUtils.IdToObject(instanceId.Value) as GameObject;
Track(go);
Assert.IsNotNull(go, "Created object should be resolvable");
Assert.IsNotNull(go.GetComponent<SphereCollider>(), "Sphere primitive should have a SphereCollider");
}
}
[Test]
public void FindGameObjects_ViaClient_ReturnsMatches()
{
var go = Track(new GameObject("ViaFind_CLI192"));
using (var server = new PipelineTestServer())
{
var response = server.Execute("find_gameobjects", new { name = "ViaFind_CLI192" });
Assert.IsTrue(response.IsSuccess, $"find_gameobjects should succeed: {response.Error}");
var count = response.JsonResponse["result"]?["count"]?.ToObject<int>();
Assert.AreEqual(1, count, "Should find exactly the one named GameObject");
}
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 661e4b429e2643e185927cf6459c3e25
@@ -0,0 +1,190 @@
using System.Collections;
using System.IO;
using System.Threading.Tasks;
using NUnit.Framework;
using Unity.Pipeline.Compilation;
using Unity.Pipeline.HotReload;
using UnityEngine;
using UnityEngine.TestTools;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Tests for the new assemblyDir parameter functionality in hot reload compilation.
/// Verifies that assemblies are saved to disk when assemblyDir is specified, and remain in-memory when not specified.
/// </summary>
public class HotReloadAssemblyDirectoryTests
{
private string testHotReloadDir;
private string testAssemblyDir;
private string validHotReloadFile;
[SetUp]
public void SetUp()
{
// Write source files OUTSIDE Assets/ (project-root Temp/, ignored by Unity) so they are
// never imported/compiled by the editor → no domain reload that would kill the live
// server. Absolute paths are passed to the compiler (GetHotReloadPath returns them as-is).
testHotReloadDir = Path.GetFullPath(Path.Combine("Temp", "PipelineHotReloadTests", "AssemblyDirTests"));
testAssemblyDir = Path.Combine("TestAssemblies", "HotReload");
Directory.CreateDirectory(testHotReloadDir);
Directory.CreateDirectory(testAssemblyDir);
validHotReloadFile = Path.Combine(testHotReloadDir, "AssemblyDirTest.cs");
// Create a valid hot reload file
File.WriteAllText(validHotReloadFile, @"
using Unity.Pipeline.HotReload;
using UnityEngine;
// Target class with reloadable method
public class AssemblyDirTestClass
{
public int value = 42;
[HotReloadWithOverrides(Id = ""AssemblyDirTest.TestMethod"")]
public void TestMethod()
{
value = 123;
}
}
// Hot reload override
public static class AssemblyDirTestHotReload
{
[HotReloadOverrideMethod(""AssemblyDirTest.TestMethod"")]
public static void TestMethod(AssemblyDirTestClass instance)
{
instance.value = 999;
Debug.Log(""Assembly directory test hot reload method executed"");
}
}");
// Clear state
HotReloadRegistry.ClearAllForTesting();
}
[TearDown]
public void TearDown()
{
// Clean up test files and directories
if (Directory.Exists(testHotReloadDir))
{
Directory.Delete(testHotReloadDir, true);
}
if (Directory.Exists(testAssemblyDir))
{
Directory.Delete(testAssemblyDir, true);
}
if (Directory.Exists("TestAssemblies"))
{
Directory.Delete("TestAssemblies", true);
}
// Clean up hot reload state
HotReloadRegistry.ClearAllForTesting();
HotReloadCompiler.CleanupHotReloadDlls(); // Default cleanup
}
[Test]
public async Task CompileAndApplyAsync_WithoutAssemblyDir_AssemblyStaysInMemory()
{
// Act - Compile without assemblyDir (should remain in-memory)
var result = await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
// Assert
Assert.IsTrue(result.IsSuccess, $"Compilation should succeed. Error: {result.Error}");
Assert.IsNotEmpty(result.AssemblyName, "Should have assembly name");
// The assembly should not exist in our test directory
var expectedDiskPath = Path.Combine(testAssemblyDir, $"{result.AssemblyName}.dll");
Assert.IsFalse(File.Exists(expectedDiskPath), "Assembly should not be saved to test directory");
// But hot reload should still work (assembly is in memory)
var stats = HotReloadRegistry.GetStats();
Assert.Greater(stats.ActiveOverrideCount, 0, "Should have registered hot reload methods");
}
[Test]
public async Task CompileAndApplyAsync_WithAssemblyDir_AssemblyIsSavedToDisk()
{
// Act - Compile with assemblyDir (should save to disk)
var result = await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile, testAssemblyDir);
// Assert
Assert.IsTrue(result.IsSuccess, $"Compilation should succeed. Error: {result.Error}");
Assert.IsNotEmpty(result.AssemblyName, "Should have assembly name");
// The assembly should exist on disk
var expectedDiskPath = Path.Combine(testAssemblyDir, $"{result.AssemblyName}.dll");
Assert.IsTrue(File.Exists(expectedDiskPath), $"Assembly should be saved to disk: {expectedDiskPath}");
// Verify file size is reasonable (should be a valid assembly)
var fileInfo = new FileInfo(expectedDiskPath);
Assert.Greater(fileInfo.Length, 1000, "Assembly file should have reasonable size");
// Hot reload should still work (assembly is loaded from memory)
var stats = HotReloadRegistry.GetStats();
Assert.Greater(stats.ActiveOverrideCount, 0, "Should have registered hot reload methods");
}
[Test]
public async Task CompileAndApplyAsync_WithAssemblyDir_MultipleVersionsCreateSeparateFiles()
{
// Expected errors: signature mismatches for versions 2+ due to cross-assembly type incompatibility
LogAssert.Expect(LogType.Error, "HotReload: Signature mismatch for 'AssemblyDirTest.TestMethod'. Override method must have instance parameter as first argument.");
LogAssert.Expect(LogType.Error, "HotReload: Signature mismatch for 'AssemblyDirTest.TestMethod'. Override method must have instance parameter as first argument.");
// Act - Compile same file multiple times to create versioned assemblies
var result1 = await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile, testAssemblyDir);
var result2 = await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile, testAssemblyDir);
var result3 = await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile, testAssemblyDir);
// Assert - All compilations should succeed
Assert.IsTrue(result1.IsSuccess, "First compilation should succeed");
Assert.IsTrue(result2.IsSuccess, "Second compilation should succeed");
Assert.IsTrue(result3.IsSuccess, "Third compilation should succeed");
// Assert - Each should have different version numbers
Assert.That(result1.AssemblyName, Does.Contain("_001"), "First version should be 001");
Assert.That(result2.AssemblyName, Does.Contain("_002"), "Second version should be 002");
Assert.That(result3.AssemblyName, Does.Contain("_003"), "Third version should be 003");
// Assert - All versions should exist on disk
var diskPath1 = Path.Combine(testAssemblyDir, $"{result1.AssemblyName}.dll");
var diskPath2 = Path.Combine(testAssemblyDir, $"{result2.AssemblyName}.dll");
var diskPath3 = Path.Combine(testAssemblyDir, $"{result3.AssemblyName}.dll");
Assert.IsTrue(File.Exists(diskPath1), $"Version 1 should exist: {diskPath1}");
Assert.IsTrue(File.Exists(diskPath2), $"Version 2 should exist: {diskPath2}");
Assert.IsTrue(File.Exists(diskPath3), $"Version 3 should exist: {diskPath3}");
}
[Test]
public void CleanupHotReloadDlls_WithAssemblyDir_CleansUpDiskFiles()
{
// Arrange - Create some test assembly files
var testAssembly1 = Path.Combine(testAssemblyDir, "TestAssembly_001.dll");
var testAssembly2 = Path.Combine(testAssemblyDir, "TestAssembly_002.dll");
var testAssembly3 = Path.Combine(testAssemblyDir, "TestAssembly_003.dll");
File.WriteAllText(testAssembly1, "fake dll content 1");
File.WriteAllText(testAssembly2, "fake dll content 2");
File.WriteAllText(testAssembly3, "fake dll content 3");
// Verify files exist before cleanup
Assert.IsTrue(File.Exists(testAssembly1), "Test file 1 should exist before cleanup");
Assert.IsTrue(File.Exists(testAssembly2), "Test file 2 should exist before cleanup");
Assert.IsTrue(File.Exists(testAssembly3), "Test file 3 should exist before cleanup");
// Act - Cleanup the specified directory
var result = HotReloadCompiler.CleanupHotReloadDlls(testAssemblyDir);
// Assert - Cleanup should succeed
Assert.IsTrue(result.Success, $"Cleanup should succeed. Message: {result.Message}");
// Note: Cleanup only removes older versions, keeping the latest, so some files may remain
// The important thing is that the cleanup operated on the correct directory
}
}
}
@@ -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.HotReload;
using UnityEngine;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Tests specifically for HotReloadMethod attribute compilation and reflection.
/// Helps diagnose issues with attribute detection in compiled assemblies.
/// </summary>
public class HotReloadAttributeTests
{
[Test]
public void HotReloadOverrideMethodAttribute_DirectUsage_CanBeReflected()
{
// Test that the attribute can be found via reflection on a directly compiled class
var method = typeof(TestHotReloadClass).GetMethod("TestOverrideMethod");
Assert.IsNotNull(method, "Test method should exist");
var attribute = method.GetCustomAttribute<HotReloadOverrideMethodAttribute>();
Assert.IsNotNull(attribute, "HotReloadMethod attribute should be found");
Assert.AreEqual("TestTarget.TestMethod", attribute.TargetMethodId, "Target method ID should match");
}
[Test]
public void RoslynCompilationService_WithHotReloadAttribute_CompilesAndReflects()
{
// Test that RoslynCompilationService can compile code with HotReloadMethod attribute
// and the attribute can be reflected from the compiled assembly
var sourceCode = @"
using Unity.Pipeline.HotReload;
using UnityEngine;
public static class CompiledHotReloadClass
{
[HotReloadOverrideMethod(""CompiledTarget.CompiledMethod"")]
public static void CompiledOverrideMethod(object instance)
{
Debug.Log(""Compiled hot reload method"");
}
}";
var request = new CompilationRequest
{
SourceCode = sourceCode,
AssemblyName = "HotReloadAttributeTest",
AdditionalAssemblyPrefixes = new[] { "Unity.Pipeline" }
};
var result = RoslynCompilationService.Compile(request);
Assert.IsTrue(result.Success, $"Compilation should succeed. Errors: {string.Join(", ", result.Diagnostics?.Select(d => d.Message) ?? new string[0])}");
Assert.IsNotNull(result.Assembly, "Should return compiled assembly");
// Find the compiled class and method
var compiledType = result.Assembly.GetType("CompiledHotReloadClass");
Assert.IsNotNull(compiledType, "Compiled class should exist");
var compiledMethod = compiledType.GetMethod("CompiledOverrideMethod");
Assert.IsNotNull(compiledMethod, "Compiled method should exist");
// Check if attribute can be found via reflection
var customAttributes = compiledMethod.GetCustomAttributes(true);
var hotReloadAttribute = compiledMethod.GetCustomAttribute<HotReloadOverrideMethodAttribute>();
if (hotReloadAttribute == null)
{
// Try to find by name
var attributeByName = customAttributes.FirstOrDefault(a => a.GetType().Name == "HotReloadOverrideMethodAttribute");
if (attributeByName != null)
{
hotReloadAttribute = attributeByName as HotReloadOverrideMethodAttribute;
}
}
Assert.IsNotNull(hotReloadAttribute, "HotReloadMethod attribute should be found on compiled method");
Assert.AreEqual("CompiledTarget.CompiledMethod", hotReloadAttribute.TargetMethodId, "Target method ID should match");
}
[Test]
public void MetadataReferences_IncludeUnityPipeline_CanFindHotReloadTypes()
{
// Verify that Unity.Pipeline assemblies are included in metadata references
var references = RoslynCompilationService.GetMetadataReferences(new[] { "Unity.Pipeline" });
var pipelineReferences = references.Where(r => r.Display?.Contains("Unity.Pipeline") == true).ToList();
Assert.Greater(pipelineReferences.Count, 0, "Should find Unity.Pipeline assembly references");
}
/// <summary>
/// Test class with HotReloadMethod attribute for direct testing.
/// </summary>
public static class TestHotReloadClass
{
[HotReloadOverrideMethod("TestTarget.TestMethod")]
public static void TestOverrideMethod(object instance)
{
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1b46f0c74679e854288a8933221a317a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,179 @@
using System.Collections;
using NUnit.Framework;
using Unity.Pipeline.HotReload;
using UnityEngine;
using UnityEngine.TestTools;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Basic tests for hot reload Pattern A method override functionality.
/// Tests the end-to-end flow: mark method -> create override -> compile -> verify behavior.
/// </summary>
public class HotReloadBasicTests
{
/// <summary>
/// Test class with hot reloadable methods for testing.
/// </summary>
public class TestComponent : MonoBehaviour
{
public int lastCalculatedValue;
public string lastMessage;
[HotReloadWithOverrides]
public void UpdateValue()
{
lastCalculatedValue = 10; // Original behavior
lastMessage = "Original UpdateValue called";
}
[HotReloadWithOverrides(Id = "CustomCalculation")]
public int CalculateScore(int input)
{
return input * 2; // Original calculation
}
}
private TestComponent testComponent;
[SetUp]
public void SetUp()
{
// Create test component
var go = new GameObject("HotReloadTestObject");
testComponent = go.AddComponent<TestComponent>();
// Clear any existing hot reload state (including reloadable methods for test isolation)
HotReloadRegistry.ClearAllForTesting();
}
[TearDown]
public void TearDown()
{
if (testComponent != null && testComponent.gameObject != null)
{
Object.DestroyImmediate(testComponent.gameObject);
}
// Clean up hot reload state
HotReloadRegistry.ClearAllForTesting();
}
[Test]
public void HotReloadRegistry_RegisterReloadableMethod_Success()
{
// Arrange
var method = typeof(TestComponent).GetMethod("UpdateValue");
var attribute = new HotReloadWithOverridesAttribute();
// Act
HotReloadRegistry.RegisterReloadableMethod(method, attribute);
// Assert
var stats = HotReloadRegistry.GetStats();
Assert.That(stats.ReloadableMethodCount, Is.EqualTo(1));
Assert.That(stats.ReloadableMethodIds.Contains("TestComponent.UpdateValue"));
}
[Test]
public void HotReloadRegistry_RegisterReloadableMethodWithCustomId_Success()
{
// Arrange
var method = typeof(TestComponent).GetMethod("CalculateScore");
var attribute = new HotReloadWithOverridesAttribute { Id = "CustomCalculation" };
// Act
HotReloadRegistry.RegisterReloadableMethod(method, attribute);
// Assert
var stats = HotReloadRegistry.GetStats();
Assert.That(stats.ReloadableMethodCount, Is.EqualTo(1));
Assert.That(stats.ReloadableMethodIds.Contains("CustomCalculation"));
}
[Test]
public void HotReloadRegistry_TryInvokeHotReload_NoOverrideReturnsFalse()
{
// Arrange
var method = typeof(TestComponent).GetMethod("UpdateValue");
var attribute = new HotReloadWithOverridesAttribute();
HotReloadRegistry.RegisterReloadableMethod(method, attribute);
// Act
var result = HotReloadRegistry.TryInvokeHotReload("TestComponent.UpdateValue", testComponent);
// Assert
Assert.That(result, Is.False, "Should return false when no hot reload override is registered");
}
[Test]
public void HotReloadRegistry_GetStats_ReturnsCorrectCounts()
{
// Arrange - register multiple methods
var updateMethod = typeof(TestComponent).GetMethod("UpdateValue");
var calculateMethod = typeof(TestComponent).GetMethod("CalculateScore");
HotReloadRegistry.RegisterReloadableMethod(updateMethod, new HotReloadWithOverridesAttribute());
HotReloadRegistry.RegisterReloadableMethod(calculateMethod, new HotReloadWithOverridesAttribute { Id = "CustomCalculation" });
// Act
var stats = HotReloadRegistry.GetStats();
// Assert
Assert.That(stats.ReloadableMethodCount, Is.EqualTo(2));
Assert.That(stats.ActiveOverrideCount, Is.EqualTo(0));
Assert.That(stats.LoadedTypeCount, Is.EqualTo(0));
Assert.That(stats.ReloadableMethodIds.Count, Is.EqualTo(2));
}
[Test]
public void HotReloadRegistry_ClearAllOverrides_ClearsAllState()
{
// Arrange - register some methods and mock some overrides
var method = typeof(TestComponent).GetMethod("UpdateValue");
HotReloadRegistry.RegisterReloadableMethod(method, new HotReloadWithOverridesAttribute());
// Act
HotReloadRegistry.ClearAllOverrides();
// Assert
var stats = HotReloadRegistry.GetStats();
Assert.That(stats.ActiveOverrideCount, Is.EqualTo(0));
Assert.That(stats.LoadedTypeCount, Is.EqualTo(0));
// Note: ReloadableMethodCount might not be cleared as those are the original methods, not overrides
// This depends on implementation - currently reloadable methods are not cleared, only overrides
}
[UnityTest]
public IEnumerator HotReloadRegistry_MainThreadDispatch_WorksCorrectly()
{
// Arrange
var method = typeof(TestComponent).GetMethod("UpdateValue");
var attribute = new HotReloadWithOverridesAttribute { RequireMainThread = true };
HotReloadRegistry.RegisterReloadableMethod(method, attribute);
// Act - test that method can be invoked (even without override, for thread safety)
var result = HotReloadRegistry.TryInvokeHotReload("TestComponent.UpdateValue", testComponent);
// Assert
Assert.That(result, Is.False); // No override registered, should return false
// Yield to ensure any async operations complete
yield return null;
}
[Test]
public void TestComponent_OriginalBehavior_WorksCorrectly()
{
// Arrange & Act - call original methods to establish baseline
testComponent.UpdateValue();
var score = testComponent.CalculateScore(5);
// Assert
Assert.That(testComponent.lastCalculatedValue, Is.EqualTo(10));
Assert.That(testComponent.lastMessage, Is.EqualTo("Original UpdateValue called"));
Assert.That(score, Is.EqualTo(10)); // 5 * 2
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 96d537e6f81b9f140a08fabafd10500e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,232 @@
using System.Collections;
using System.IO;
using NUnit.Framework;
using Unity.Pipeline.Compilation;
using Unity.Pipeline.HotReload;
using Unity.Pipeline.Runtime.Commands;
using UnityEngine;
using UnityEngine.TestTools;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Integration tests for hot reload CLI commands.
/// Tests the complete workflow: CLI commands → compiler → registry → runtime behavior.
/// </summary>
public class HotReloadCommandTests
{
[SetUp]
public void SetUp()
{
// Clear hot reload state
HotReloadRegistry.ClearAllForTesting();
}
[TearDown]
public void TearDown()
{
HotReloadRegistry.ClearAllForTesting();
}
[Test]
public void HotReloadStatus_ReturnsStats()
{
// Arrange - register a test method
var method = typeof(SimpleTestClass).GetMethod("TestMethod");
HotReloadRegistry.RegisterReloadableMethod(method, new HotReloadWithOverridesAttribute());
// Act
var result = HotReloadCommands.HotReloadStatus();
// Assert
Assert.That(result.Success, Is.True);
Assert.That(result.Message, Contains.Substring("Reloadable Methods: 1"));
Assert.That(result.Message, Contains.Substring("Active Overrides: 0"));
}
[Test]
public void ReloadFile_WithInvalidFilename_ReturnsBadRequest()
{
// Act
var result = HotReloadCommands.ReloadFileOverride("");
// Assert
Assert.That(result.Success, Is.False);
Assert.That(result.Error, Is.EqualTo("Bad Request"));
Assert.That(result.ErrorDetails, Contains.Substring("cannot be empty"));
}
[Test]
public void ReloadFile_WithInvalidTimeout_ReturnsBadRequest()
{
// Act
var result = HotReloadCommands.ReloadFileOverride("test.cs", timeout: -1);
// Assert
Assert.That(result.Success, Is.False);
Assert.That(result.Error, Is.EqualTo("Bad Request"));
Assert.That(result.ErrorDetails, Contains.Substring("Timeout must be between"));
}
[Test]
public void ReloadFile_WithNonExistentFile_ReturnsFileNotFound()
{
// Act
var result = HotReloadCommands.ReloadFileOverride("NonExistentFile.cs");
// Assert
Assert.That(result.Success, Is.False);
Assert.That(result.Error, Is.EqualTo("File Not Found"));
Assert.That(result.ErrorDetails, Contains.Substring("not found"));
}
[Test]
public void ReloadFile_FileWithoutOverrides_ReturnsInvalidOverrideFile()
{
// A plain gameplay file (no [HotReloadOverrideMethod]) is not an override file.
var path = Path.GetFullPath(Path.Combine("Temp", "PipelineHotReloadTests", "CommandTests", "NoOverrides.cs"));
Directory.CreateDirectory(Path.GetDirectoryName(path));
File.WriteAllText(path, "using UnityEngine;\npublic class Plain : MonoBehaviour { void Update() { } }");
try
{
var result = HotReloadCommands.ReloadFileOverride(path);
Assert.That(result.Success, Is.False);
Assert.That(result.Error, Is.EqualTo("Invalid Override File"));
Assert.That(result.ErrorDetails, Contains.Substring("No [HotReloadOverrideMethod]"));
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void ReloadFile_OverrideRedeclaresTargetType_ReturnsInvalidOverrideFile()
{
// The override targets Boss but the file also declares Boss -> redeclaration is rejected.
var path = Path.GetFullPath(Path.Combine("Temp", "PipelineHotReloadTests", "CommandTests", "SameFile.cs"));
Directory.CreateDirectory(Path.GetDirectoryName(path));
File.WriteAllText(path, @"
using Unity.Pipeline.HotReload;
using UnityEngine;
public class Boss : MonoBehaviour
{
[HotReloadOverrideMethod(""Boss.Update"")]
public static void Tweaked(Boss instance) { }
}");
try
{
var result = HotReloadCommands.ReloadFileOverride(path);
Assert.That(result.Success, Is.False);
Assert.That(result.Error, Is.EqualTo("Invalid Override File"));
Assert.That(result.ErrorDetails, Contains.Substring("Boss"));
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void ReloadFileInPlace_NonExistentFile_ReturnsFileNotFound()
{
var result = HotReloadCommands.ReloadFile("NonExistentInPlaceFile.cs");
Assert.That(result.Success, Is.False);
Assert.That(result.Error, Is.EqualTo("File Not Found"));
}
[UnityTest]
public IEnumerator ReloadFile_WithValidFile_CompilationProcess()
{
// This test would require a valid hot reload file to exist
// For now, we just test that the command structure works
// Arrange - create a simple test file OUTSIDE Assets/ (project-root Temp/, ignored by
// Unity) so it isn't imported/compiled → no domain reload that would kill the live server.
// Absolute path is passed to ReloadFileOverride, whose GetHotReloadPath returns it as-is.
var testFilePath = Path.GetFullPath(Path.Combine("Temp", "PipelineHotReloadTests", "CommandTests", "TestCommandFile.cs"));
var testFileContent = @"
using Unity.Pipeline.HotReload;
using UnityEngine;
public static class TestCommandFile
{
[HotReloadOverrideMethod(""SimpleTestClass.TestMethod"")]
public static void TestMethod(SimpleTestClass instance)
{
instance.value = 999;
}
}";
Directory.CreateDirectory(Path.GetDirectoryName(testFilePath));
File.WriteAllText(testFilePath, testFileContent);
try
{
// Act
var result = HotReloadCommands.ReloadFileOverride(testFilePath);
// Assert - compilation should be attempted (may fail due to missing references, but structure should work)
Assert.That(result, Is.Not.Null);
// Note: This test mainly verifies the command structure and error handling
// Full compilation tests would require proper assembly setup
yield return null; // Unity test requirement
}
finally
{
// Cleanup
if (File.Exists(testFilePath))
{
File.Delete(testFilePath);
}
}
}
[Test]
public void HotReloadResponse_Success_CreatesCorrectResponse()
{
// Act
var response = HotReloadResponse.CmdSuccess("test-assembly", "Test message", new System.Collections.Generic.List<string> { "method1", "method2" }, 1500);
// Assert
Assert.That(response.Success, Is.True);
Assert.That(response.AssemblyName, Is.EqualTo("test-assembly"));
Assert.That(response.Message, Is.EqualTo("Test message"));
Assert.That(response.Items.Count, Is.EqualTo(2));
Assert.That(response.ExecutionTimeMs, Is.EqualTo(1500));
}
[Test]
public void HotReloadResponse_Failure_CreatesCorrectResponse()
{
// Act
var response = HotReloadResponse.CmdFailure("Test Error", "Detailed error message", 2000);
// Assert
Assert.That(response.Success, Is.False);
Assert.That(response.Error, Is.EqualTo("Test Error"));
Assert.That(response.ErrorDetails, Is.EqualTo("Detailed error message"));
Assert.That(response.ExecutionTimeMs, Is.EqualTo(2000));
}
/// <summary>
/// Simple test class for hot reload testing.
/// </summary>
public class SimpleTestClass
{
public int value;
public void TestMethod()
{
value = 42;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3f81555b71859b94c8349361be0d1a9b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,362 @@
using System.Collections;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using Unity.Pipeline.Compilation;
using Unity.Pipeline.HotReload;
using UnityEngine;
using UnityEngine.TestTools;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Tests for HotReloadCompiler to verify refactoring, deadlock fix, and core functionality.
/// Tests the complete workflow: file reading → compilation → method registration.
/// </summary>
public class HotReloadCompilerTests
{
private string testHotReloadDir;
private string validHotReloadFile;
private string invalidHotReloadFile;
private string emptyHotReloadFile;
private int m_MainThreadId;
[SetUp]
public void SetUp()
{
// Write test source files OUTSIDE Assets/ (project-root Temp/, which Unity ignores) so
// they are never imported/compiled by the editor — importing a .cs under Assets/ triggers
// a domain reload that tears down the live pipeline server. Absolute paths are passed to
// the compiler, whose GetHotReloadPath returns rooted paths as-is (no Assets/ prefix).
testHotReloadDir = Path.GetFullPath(Path.Combine("Temp", "PipelineHotReloadTests", "CompilerTests"));
Directory.CreateDirectory(testHotReloadDir);
validHotReloadFile = Path.Combine(testHotReloadDir, "ValidTest.cs");
invalidHotReloadFile = Path.Combine(testHotReloadDir, "InvalidTest.cs");
emptyHotReloadFile = Path.Combine(testHotReloadDir, "EmptyTest.cs");
// Create valid hot reload file with both reloadable target and hot reload override
File.WriteAllText(validHotReloadFile, @"
using Unity.Pipeline.HotReload;
using UnityEngine;
// Target class with reloadable method
public class TestClass
{
public int value = 42;
[HotReloadWithOverrides(Id = ""TestClass.TestMethod"")]
public void TestMethod()
{
value = 123;
}
}
// Hot reload override
public static class ValidTestHotReload
{
[HotReloadOverrideMethod(""TestClass.TestMethod"")]
public static void TestMethod(TestClass instance)
{
instance.value = 999;
Debug.Log(""Hot reload method executed"");
}
}");
// Create invalid hot reload file (syntax error)
File.WriteAllText(invalidHotReloadFile, @"
using Unity.Pipeline.HotReload;
// Need to define TestClass since we're not wrapping code anymore
public class TestClass
{
public int value = 42;
[HotReloadWithOverrides(Id = ""TestClass.TestMethod"")]
public void TestMethod()
{
value = 123;
}
}
public static class InvalidTestHotReload
{
[HotReloadOverrideMethod(""TestClass.TestMethod"")]
public static void TestMethod(TestClass instance)
{
instance.value = 999 +; // Syntax error
}
}");
// Create empty file
File.WriteAllText(emptyHotReloadFile, "");
// Clear hot reload state
HotReloadRegistry.ClearAllForTesting();
// Capture the main thread so tests can assert thread context.
m_MainThreadId = Thread.CurrentThread.ManagedThreadId;
}
[TearDown]
public void TearDown()
{
// Clean up test files
if (Directory.Exists(testHotReloadDir))
{
Directory.Delete(testHotReloadDir, true);
}
// Clean up hot reload state and temp files
HotReloadRegistry.ClearAllForTesting();
HotReloadCompiler.CleanupHotReloadDlls();
}
[Test]
public void CompileAndApplyAsync_FromMainThread_RunsSynchronously()
{
// Arrange - We're on main thread
Assert.AreEqual(m_MainThreadId, Thread.CurrentThread.ManagedThreadId, "Test should run on main thread");
// Act - This should not deadlock (the main fix being tested)
var task = HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
// Assert - Should complete synchronously without blocking
Assert.IsTrue(task.IsCompleted, "Should complete synchronously on main thread to avoid deadlock");
var result = task.Result;
Assert.IsTrue(result.IsSuccess, $"Valid hot reload should succeed. Error: {result.Error}");
Assert.IsNotEmpty(result.AssemblyName, "Should have assembly name");
Assert.Greater(result.ExecutionTimeMs, 0, "Should record execution time");
}
[Test]
public async Task CompileAndApplyAsync_FromBackgroundThread_UsesAsyncPath()
{
// Arrange - Track thread IDs
var mainThreadId = Thread.CurrentThread.ManagedThreadId;
var backgroundThreadId = -1;
HotReloadCompileResult result = null;
// Act - Run from background thread
await Task.Run(async () =>
{
backgroundThreadId = Thread.CurrentThread.ManagedThreadId;
Assert.AreNotEqual(mainThreadId, backgroundThreadId, "Should be on different thread");
Assert.AreNotEqual(m_MainThreadId, Thread.CurrentThread.ManagedThreadId, "Should not be on main thread");
result = await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
});
// Assert
Assert.IsNotNull(result, "Should get result");
Assert.IsTrue(result.IsSuccess, $"Background compilation should succeed. Error: {result.Error}");
Assert.IsNotEmpty(result.AssemblyName, "Should have assembly name");
}
[Test]
public async Task CompileAndApplyAsync_ValidFile_RegistersHotReloadMethods()
{
// Get initial stats for comparison
var initialStats = HotReloadRegistry.GetStats();
// Act
var result = await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
// Assert compilation success
Assert.IsTrue(result.IsSuccess, $"Should succeed. Error: {result.Error}");
Assert.Greater(result.RegisteredMethods.Count, 0, "Should register at least one method");
Assert.Contains("TestClass.TestMethod", result.RegisteredMethods, "Should register the specified target method");
// Get final stats and compare
var finalStats = HotReloadRegistry.GetStats();
// Verify method was registered in registry
Assert.Greater(finalStats.ActiveOverrideCount, initialStats.ActiveOverrideCount, $"Active override count should increase from {initialStats.ActiveOverrideCount} to more than {initialStats.ActiveOverrideCount}");
}
[Test]
public async Task CompileAndApplyAsync_InvalidSyntax_ReturnsCompilationError()
{
// Act
var result = await HotReloadCompiler.CompileAndApplyAsync(invalidHotReloadFile);
// Assert
Assert.IsFalse(result.IsSuccess, "Invalid syntax should fail compilation");
Assert.AreEqual("Compilation Failed", result.Error, "Should return compilation failed error");
Assert.IsNotNull(result.Diagnostics, "Should have diagnostics");
Assert.Greater(result.Diagnostics.Count, 0, "Should have compilation diagnostics");
}
[Test]
public async Task CompileAndApplyAsync_EmptyFile_ReturnsEmptyFileError()
{
// Act
var result = await HotReloadCompiler.CompileAndApplyAsync(emptyHotReloadFile);
// Assert
Assert.IsFalse(result.IsSuccess, "Empty file should fail");
Assert.AreEqual("Empty File", result.Error, "Should return empty file error");
Assert.That(result.ErrorDetails, Does.Contain("empty"), "Should mention file is empty");
}
[Test]
public async Task CompileAndApplyAsync_FileNotFound_ReturnsFileNotFoundError()
{
// Act
var result = await HotReloadCompiler.CompileAndApplyAsync("NonExistentFile.cs");
// Assert
Assert.IsFalse(result.IsSuccess, "Non-existent file should fail");
Assert.AreEqual("File Not Found", result.Error, "Should return file not found error");
Assert.That(result.ErrorDetails, Does.Contain("not found"), "Should mention file not found");
}
[Test]
public async Task CompileAndApplyAsync_VersionTracking_GeneratesIncrementingVersions()
{
// Expected errors: signature mismatches for versions 2+ due to cross-assembly type incompatibility
LogAssert.Expect(LogType.Error, "HotReload: Signature mismatch for 'TestClass.TestMethod'. Override method must have instance parameter as first argument.");
LogAssert.Expect(LogType.Error, "HotReload: Signature mismatch for 'TestClass.TestMethod'. Override method must have instance parameter as first argument.");
// Act - Compile the same file multiple times
var result1 = await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
var result2 = await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
var result3 = await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
// Assert - Each compilation should have incrementing version numbers
Assert.IsTrue(result1.IsSuccess, "First compilation should succeed");
Assert.IsTrue(result2.IsSuccess, "Second compilation should succeed");
Assert.IsTrue(result3.IsSuccess, "Third compilation should succeed");
// Assembly names should contain version numbers
Assert.That(result1.AssemblyName, Does.Contain("_001"), "First version should be 001");
Assert.That(result2.AssemblyName, Does.Contain("_002"), "Second version should be 002");
Assert.That(result3.AssemblyName, Does.Contain("_003"), "Third version should be 003");
}
[Test]
public void CleanupHotReloadDlls_WithNoFiles_SucceedsGracefully()
{
// Act
var result = HotReloadCompiler.CleanupHotReloadDlls();
// Assert
Assert.IsTrue(result.Success, "Cleanup should succeed even when no files exist");
Assert.AreEqual(0, result.DeletedFiles.Count, "Should not delete any files");
Assert.That(result.Message, Does.Contain("No hot reload temp directory").Or.Contain("0 old DLL versions"));
}
[Test]
public async Task CleanupHotReloadDlls_AfterCompilations_CleansUpOldVersions()
{
// Expected errors: signature mismatches for versions 2+ due to cross-assembly type incompatibility
LogAssert.Expect(LogType.Error, "HotReload: Signature mismatch for 'TestClass.TestMethod'. Override method must have instance parameter as first argument.");
LogAssert.Expect(LogType.Error, "HotReload: Signature mismatch for 'TestClass.TestMethod'. Override method must have instance parameter as first argument.");
// Arrange - Create multiple versions
await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
// Act - Cleanup
var cleanupResult = HotReloadCompiler.CleanupHotReloadDlls();
// Assert
Assert.IsTrue(cleanupResult.Success, "Cleanup should succeed");
Assert.GreaterOrEqual(cleanupResult.ExecutionTimeMs, 0, "Should record execution time (0ms is acceptable for fast cleanup)");
// Registry should be cleared
var stats = HotReloadRegistry.GetStats();
Assert.AreEqual(0, stats.ActiveOverrideCount, "Registry should be cleared after cleanup");
}
[Test]
public async Task CompileAndApplyAsync_UsesRoslynCompilationService_Integration()
{
// This test verifies that HotReloadCompiler properly integrates with RoslynCompilationService
// Act
var result = await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
// Assert - Verify it uses the shared service (indirect verification through successful compilation)
Assert.IsTrue(result.IsSuccess, "Should successfully compile using shared RoslynCompilationService");
Assert.IsNotEmpty(result.AssemblyName, "Should generate assembly name");
// The fact that it compiles successfully with the same diagnostics format as EvalCodeCompiler
// indicates it's using the shared RoslynCompilationService
if (!result.IsSuccess && result.Diagnostics?.Any() == true)
{
// If there were diagnostics, they should be in the same format as RoslynCompilationService
var diagnostic = result.Diagnostics.First();
Assert.IsNotEmpty(diagnostic, "Diagnostic should not be empty");
}
}
[Test]
public async Task CompileAndApplyAsync_ResolvesPaths_AbsoluteAndRelative()
{
// Absolute paths are used as-is.
var result = await HotReloadCompiler.CompileAndApplyAsync(validHotReloadFile);
Assert.IsTrue(result.IsSuccess, $"Should find file by absolute path. Error: {result.Error}");
// Relative paths resolve against the project root (no special "HotReload" folder).
var result2 = await HotReloadCompiler.CompileAndApplyAsync("DoesNotExist.cs");
Assert.IsFalse(result2.IsSuccess, "Should fail for non-existent file");
Assert.That(result2.ErrorDetails, Does.Contain("DoesNotExist.cs"),
"Error should reference the resolved path");
Assert.That(result2.ErrorDetails, Does.Not.Contain("HotReload\\DoesNotExist.cs"),
"Relative paths should no longer be rooted under a HotReload folder");
}
[UnityTest]
public IEnumerator CompileAndApplyAsync_PerformanceComparison_LogsTiming()
{
// Expected error: signature mismatch for second compilation due to cross-assembly type incompatibility
LogAssert.Expect(LogType.Error, "HotReload: Signature mismatch for 'TestClass.TestMethod'. Override method must have instance parameter as first argument.");
// Test to observe performance differences between main thread and background execution
var filename = validHotReloadFile;
// Main thread execution
var sw1 = System.Diagnostics.Stopwatch.StartNew();
var mainThreadTask = HotReloadCompiler.CompileAndApplyAsync(filename);
yield return new WaitUntil(() => mainThreadTask.IsCompleted);
sw1.Stop();
var result1 = mainThreadTask.Result;
Assert.IsTrue(result1.IsSuccess, "Main thread execution should succeed");
// Background thread execution
var sw2 = System.Diagnostics.Stopwatch.StartNew();
HotReloadCompileResult result2 = null;
var backgroundTask = Task.Run(async () =>
{
result2 = await HotReloadCompiler.CompileAndApplyAsync(filename);
});
yield return new WaitUntil(() => backgroundTask.IsCompleted);
sw2.Stop();
Assert.IsNotNull(result2, "Background execution should complete");
Assert.IsTrue(result2.IsSuccess, "Background execution should succeed");
}
[Test]
public void CompileAndApplyOnMainThread_DirectCall_WorksSynchronously()
{
// Test the direct synchronous method (used internally for main thread detection)
// Act - Call the synchronous version directly
var result = HotReloadCompiler.CompileAndApplyOnMainThread(validHotReloadFile);
// Assert - Should complete synchronously
Assert.IsNotNull(result, "Should return result immediately");
Assert.IsTrue(result.IsSuccess, $"Direct synchronous call should succeed. Error: {result.Error}");
Assert.Greater(result.ExecutionTimeMs, 0, "Should record execution time");
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 22dc4e614651b5b4e8d344bd9857fcf6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,296 @@
using System.Collections.Generic;
using System.IO;
using NUnit.Framework;
using Unity.Pipeline.HotReload;
using Unity.Pipeline.Runtime.Commands;
using UnityEngine;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Top-level probe type woven by the HotReloadInPlace ILPostProcessor. Must be top-level (not
/// nested) so the generated override can reference it by name from a separate assembly.
/// </summary>
public class InPlaceReloadProbe
{
public int baseline;
public int hotValue;
[HotReload]
public void Compute()
{
baseline = 1; // original body
}
}
/// <summary>
/// Tests for the in-place hot reload workflow, covering all three layers:
/// - compile-time IL weaving + runtime dispatch (the ILPostProcessor),
/// - the Roslyn semantic-model transform + accessibility validation,
/// - the full reload_file pipeline end to end.
/// </summary>
public class HotReloadInPlaceTests
{
// -------- weaving fixtures --------
public class WeaveTarget
{
public int ticks;
public bool overrideRan;
[HotReload]
public void Tick()
{
ticks++; // original body
}
}
public static class WeaveOverrides
{
[HotReloadOverrideMethod("WeaveTarget.Tick")]
public static void Tick(WeaveTarget instance)
{
instance.overrideRan = true;
}
}
private const string PublicSource = @"
using UnityEngine;
using Unity.Pipeline.HotReload;
public class Demo : MonoBehaviour
{
public float speed = 1f;
[HotReload]
void Tick()
{
var dt = Time.deltaTime;
transform.position += Vector3.right * speed * dt;
}
}";
private static Dictionary<string, string> Bodies(params string[] names)
{
var d = new Dictionary<string, string>();
foreach (var n in names) d[n] = "";
return d;
}
[SetUp]
public void Setup() => HotReloadRegistry.ClearAllForTesting();
[TearDown]
public void TearDown() => HotReloadRegistry.ClearAllForTesting();
// -------- weaving + dispatch --------
[Test]
public void NoOverride_RunsOriginalBody()
{
var t = new WeaveTarget();
t.Tick();
Assert.AreEqual(1, t.ticks, "Original body should run when no override is registered");
Assert.IsFalse(t.overrideRan);
}
[Test]
public void WithOverride_WovenDispatchInvokesOverride()
{
HotReloadRegistry.RegisterReloadableMethod(
typeof(WeaveTarget).GetMethod(nameof(WeaveTarget.Tick)),
new HotReloadWithOverridesAttribute());
var registered = HotReloadRegistry.RegisterMethodOverride(
typeof(WeaveOverrides).GetMethod(nameof(WeaveOverrides.Tick)),
new HotReloadOverrideMethodAttribute("WeaveTarget.Tick"),
typeof(WeaveOverrides),
out var reason);
Assert.IsTrue(registered, reason);
var t = new WeaveTarget();
t.Tick();
Assert.IsTrue(t.overrideRan, "Woven dispatch should have routed Tick() to the override");
Assert.AreEqual(0, t.ticks, "Original body should be skipped when the override runs");
}
// -------- semantic transform + accessibility --------
[Test]
public void Transform_QualifiesInstanceMembers_LeavesLocalsAndStatics()
{
var output = SourceCodeTransformer.TransformMethodBodies(
Bodies("Tick"), "Demo", new Dictionary<string, MethodSignatureInfo>(), PublicSource);
StringAssert.Contains("[HotReloadOverrideMethod(\"Demo.Tick\")]", output);
StringAssert.Contains("public static void Tick(Demo instance", output);
// Instance members are qualified.
StringAssert.Contains("instance.transform", output);
StringAssert.Contains("instance.speed", output);
// Statics and locals are left alone.
StringAssert.Contains("Time.deltaTime", output);
StringAssert.Contains("Vector3.right", output);
StringAssert.DoesNotContain("instance.dt", output);
StringAssert.DoesNotContain("instance.Time", output);
StringAssert.DoesNotContain("instance.Vector3", output);
}
[Test]
public void Transform_WithLineDirectives_MapsBodyToOriginalFile()
{
// The body's opening brace sits on line 11 of PublicSource.
var path = @"C:\proj\Demo.cs";
var output = SourceCodeTransformer.TransformMethodBodies(
Bodies("Tick"), "Demo", new Dictionary<string, MethodSignatureInfo>(), PublicSource,
emitLineDirectives: true, originalFilePath: path);
// #line maps the body back to the original file (backslashes escaped for the literal),
// bracketed by #line hidden so the generated scaffolding isn't attributed to user code.
StringAssert.Contains("#line 11 \"C:\\\\proj\\\\Demo.cs\"", output);
StringAssert.Contains("#line hidden", output);
// The instance qualification still happens in this mode.
StringAssert.Contains("instance.transform", output);
StringAssert.Contains("instance.speed", output);
}
[Test]
public void Transform_WithoutLineDirectives_EmitsNoLineDirectives()
{
var output = SourceCodeTransformer.TransformMethodBodies(
Bodies("Tick"), "Demo", new Dictionary<string, MethodSignatureInfo>(), PublicSource);
StringAssert.DoesNotContain("#line", output);
}
[Test]
public void Validator_AllowsPublicMembers()
{
var result = AccessibilityValidator.ValidatePublicAccess(PublicSource, Bodies("Tick"), "Demo");
Assert.IsTrue(result.IsValid, result.GetFormattedErrorMessage());
}
[Test]
public void Validator_FlagsPrivateMember()
{
var source = @"
using UnityEngine;
using Unity.Pipeline.HotReload;
public class Demo : MonoBehaviour
{
private float secretSpeed = 1f;
[HotReload]
void Tick()
{
transform.position += Vector3.right * secretSpeed * Time.deltaTime;
}
}";
var result = AccessibilityValidator.ValidatePublicAccess(source, Bodies("Tick"), "Demo");
Assert.IsFalse(result.IsValid);
Assert.IsTrue(result.Violations.Exists(v => v.MemberName == "secretSpeed"),
"Should flag the private field 'secretSpeed'");
StringAssert.DoesNotContain("transform", result.GetFormattedErrorMessage(),
"Public/base members like 'transform' must not be flagged");
}
// -------- end to end (full reload_file pipeline) --------
[Test]
public void ReloadFileInPlace_EditedBody_AppliesViaWovenDispatch()
{
// Auto-discovery would do this at play start; register the target explicitly for the test.
HotReloadRegistry.RegisterReloadableMethod(
typeof(InPlaceReloadProbe).GetMethod(nameof(InPlaceReloadProbe.Compute)),
new HotReloadWithOverridesAttribute());
// The "edited" source: Compute now writes hotValue instead of baseline.
var editedSource = @"
using Unity.Pipeline.HotReload;
namespace Unity.Pipeline.Tests.Editor
{
public class InPlaceReloadProbe
{
public int baseline;
public int hotValue;
[HotReload]
public void Compute()
{
hotValue = 99;
}
}
}";
var path = Path.Combine(Application.temporaryCachePath, "InPlaceReloadProbe_edit.cs");
File.WriteAllText(path, editedSource);
try
{
var response = HotReloadCommands.ReloadFile(path);
Assert.IsTrue(response.Success, $"reload_file should succeed. Error: {response.ErrorDetails}");
var probe = new InPlaceReloadProbe();
probe.Compute();
Assert.AreEqual(99, probe.hotValue, "Edited [HotReload] body should run via the woven dispatch");
Assert.AreEqual(0, probe.baseline, "Original body should be skipped when the override runs");
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void ReloadFileInPlace_WithPdb_StillAppliesViaWovenDispatch()
{
// Functional parity: the --pdb path (#line directives + portable PDB, unoptimized) must
// still compile and dispatch exactly like the default path.
HotReloadRegistry.RegisterReloadableMethod(
typeof(InPlaceReloadProbe).GetMethod(nameof(InPlaceReloadProbe.Compute)),
new HotReloadWithOverridesAttribute());
var editedSource = @"
using Unity.Pipeline.HotReload;
namespace Unity.Pipeline.Tests.Editor
{
public class InPlaceReloadProbe
{
public int baseline;
public int hotValue;
[HotReload]
public void Compute()
{
hotValue = 99;
}
}
}";
var path = Path.Combine(Application.temporaryCachePath, "InPlaceReloadProbe_pdb_edit.cs");
File.WriteAllText(path, editedSource);
try
{
var response = HotReloadCommands.ReloadFile(path, pdb: true);
Assert.IsTrue(response.Success, $"reload_file --pdb should succeed. Error: {response.ErrorDetails}");
var probe = new InPlaceReloadProbe();
probe.Compute();
Assert.AreEqual(99, probe.hotValue, "Edited body should run via woven dispatch even with --pdb");
Assert.AreEqual(0, probe.baseline, "Original body should be skipped when the override runs");
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e5830856fb74475c9b4284d67bb9c991
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,220 @@
using System.Collections;
using System.IO;
using NUnit.Framework;
using Unity.Pipeline.HotReload;
using UnityEngine;
using UnityEngine.TestTools;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Integration tests for hot reload end-to-end functionality.
/// Tests the complete workflow: register methods → create overrides → compile → verify runtime behavior.
/// </summary>
public class HotReloadIntegrationTests
{
/// <summary>
/// Test component that properly integrates hot reload pattern.
/// </summary>
public class IntegratedTestComponent : MonoBehaviour
{
public int lastValue;
public string lastMessage;
public Vector3 lastMovement;
[HotReloadWithOverrides]
public void ProcessValue(int input)
{
HotReloadHelper.ExecuteWithHotReload(this, "ProcessValue",
() => OriginalProcessValue(input), input);
}
[HotReloadWithOverrides(Id = "CustomMovement")]
public void Move(Vector3 direction)
{
HotReloadHelper.ExecuteWithHotReloadCustomId(this, "CustomMovement",
() => OriginalMove(direction), direction);
}
private void OriginalProcessValue(int input)
{
lastValue = input * 2;
lastMessage = $"Original: {input} * 2 = {lastValue}";
}
private void OriginalMove(Vector3 direction)
{
lastMovement = direction * 1.5f;
}
}
/// <summary>
/// Mock hot reload override methods for testing.
/// This simulates what would be in a HotReload/TestFile.cs.
/// </summary>
public static class MockHotReloadOverrides
{
[HotReloadOverrideMethod("IntegratedTestComponent.ProcessValue")]
public static void ProcessValue(IntegratedTestComponent instance, int input)
{
instance.lastValue = input * 3; // Different calculation
instance.lastMessage = $"HotReload: {input} * 3 = {instance.lastValue}";
}
[HotReloadOverrideMethod("CustomMovement")]
public static void Move(IntegratedTestComponent instance, Vector3 direction)
{
instance.lastMovement = direction * 2.5f; // Different multiplier
}
}
private IntegratedTestComponent testComponent;
private GameObject testObject;
[SetUp]
public void SetUp()
{
// Create test component
testObject = new GameObject("IntegrationTestObject");
testComponent = testObject.AddComponent<IntegratedTestComponent>();
// Clear hot reload state (including reloadable methods for test isolation)
HotReloadRegistry.ClearAllForTesting();
// Register the test methods as hot reloadable
var processMethod = typeof(IntegratedTestComponent).GetMethod("ProcessValue");
var moveMethod = typeof(IntegratedTestComponent).GetMethod("Move");
HotReloadRegistry.RegisterReloadableMethod(processMethod, new HotReloadWithOverridesAttribute());
HotReloadRegistry.RegisterReloadableMethod(moveMethod, new HotReloadWithOverridesAttribute { Id = "CustomMovement" });
}
[TearDown]
public void TearDown()
{
if (testObject != null)
{
Object.DestroyImmediate(testObject);
}
HotReloadRegistry.ClearAllForTesting();
}
[Test]
public void IntegratedComponent_OriginalBehavior_WorksCorrectly()
{
// Act - call methods without hot reload overrides
testComponent.ProcessValue(5);
testComponent.Move(Vector3.right);
// Assert - should use original implementations
Assert.That(testComponent.lastValue, Is.EqualTo(10)); // 5 * 2
Assert.That(testComponent.lastMessage, Is.EqualTo("Original: 5 * 2 = 10"));
Assert.That(testComponent.lastMovement, Is.EqualTo(Vector3.right * 1.5f));
}
[Test]
public void IntegratedComponent_WithHotReloadOverride_UsesOverrideLogic()
{
// Arrange - register hot reload overrides
var processOverride = typeof(MockHotReloadOverrides).GetMethod("ProcessValue");
var moveOverride = typeof(MockHotReloadOverrides).GetMethod("Move");
HotReloadRegistry.RegisterMethodOverride(processOverride,
new HotReloadOverrideMethodAttribute("IntegratedTestComponent.ProcessValue"),
typeof(MockHotReloadOverrides));
HotReloadRegistry.RegisterMethodOverride(moveOverride,
new HotReloadOverrideMethodAttribute("CustomMovement"),
typeof(MockHotReloadOverrides));
// Act - call methods with hot reload overrides active
testComponent.ProcessValue(5);
testComponent.Move(Vector3.right);
// Assert - should use hot reload implementations
Assert.That(testComponent.lastValue, Is.EqualTo(15)); // 5 * 3 (hot reload version)
Assert.That(testComponent.lastMessage, Is.EqualTo("HotReload: 5 * 3 = 15"));
Assert.That(testComponent.lastMovement, Is.EqualTo(Vector3.right * 2.5f)); // hot reload version
}
[Test]
public void HotReloadHelper_IsHotReloadActive_ReturnsCorrectStatus()
{
// Arrange - no overrides initially
Assert.That(HotReloadHelper.IsHotReloadActive<IntegratedTestComponent>("ProcessValue"), Is.False);
Assert.That(HotReloadHelper.IsHotReloadActive("CustomMovement"), Is.False);
// Act - register override
var processOverride = typeof(MockHotReloadOverrides).GetMethod("ProcessValue");
HotReloadRegistry.RegisterMethodOverride(processOverride,
new HotReloadOverrideMethodAttribute("IntegratedTestComponent.ProcessValue"),
typeof(MockHotReloadOverrides));
// Assert - now should be active
Assert.That(HotReloadHelper.IsHotReloadActive<IntegratedTestComponent>("ProcessValue"), Is.True);
Assert.That(HotReloadHelper.IsHotReloadActive("CustomMovement"), Is.False);
}
[Test]
public void HotReloadRegistry_Stats_ReflectCurrentState()
{
// Arrange - register override
var processOverride = typeof(MockHotReloadOverrides).GetMethod("ProcessValue");
HotReloadRegistry.RegisterMethodOverride(processOverride,
new HotReloadOverrideMethodAttribute("IntegratedTestComponent.ProcessValue"),
typeof(MockHotReloadOverrides));
// Act
var stats = HotReloadRegistry.GetStats();
// Assert
Assert.That(stats.ReloadableMethodCount, Is.EqualTo(2)); // ProcessValue + Move
Assert.That(stats.ActiveOverrideCount, Is.EqualTo(1)); // Only ProcessValue override
Assert.That(stats.ActiveOverrideIds.Contains("IntegratedTestComponent.ProcessValue"), Is.True);
Assert.That(stats.ReloadableMethodIds.Contains("IntegratedTestComponent.ProcessValue"), Is.True);
Assert.That(stats.ReloadableMethodIds.Contains("CustomMovement"), Is.True);
}
[Test]
public void HotReloadRegistry_ClearOverrides_RestoresOriginalBehavior()
{
// Arrange - register and test override
var processOverride = typeof(MockHotReloadOverrides).GetMethod("ProcessValue");
HotReloadRegistry.RegisterMethodOverride(processOverride,
new HotReloadOverrideMethodAttribute("IntegratedTestComponent.ProcessValue"),
typeof(MockHotReloadOverrides));
testComponent.ProcessValue(5);
Assert.That(testComponent.lastValue, Is.EqualTo(15)); // Hot reload behavior
// Act - clear overrides (but keep reloadable method registrations)
HotReloadRegistry.ClearAllOverrides();
// Assert - should revert to original behavior
testComponent.ProcessValue(5);
Assert.That(testComponent.lastValue, Is.EqualTo(10)); // Original behavior
Assert.That(testComponent.lastMessage, Is.EqualTo("Original: 5 * 2 = 10"));
}
[UnityTest]
public IEnumerator HotReloadIntegration_MainThreadDispatch_WorksCorrectly()
{
// Arrange
var processOverride = typeof(MockHotReloadOverrides).GetMethod("ProcessValue");
HotReloadRegistry.RegisterMethodOverride(processOverride,
new HotReloadOverrideMethodAttribute("IntegratedTestComponent.ProcessValue"),
typeof(MockHotReloadOverrides));
// Act - invoke from main thread
testComponent.ProcessValue(7);
// Yield to allow any async operations
yield return null;
// Assert - hot reload should work on main thread
Assert.That(testComponent.lastValue, Is.EqualTo(21)); // 7 * 3
Assert.That(testComponent.lastMessage, Is.EqualTo("HotReload: 7 * 3 = 21"));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: bb9a7f677ab168b4bae8e1c0435d3e1b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,172 @@
using System.Reflection;
using NUnit.Framework;
using Unity.Pipeline.HotReload;
using UnityEngine;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Tests for HotReloadRegistry to verify method registration and statistics.
/// Helps diagnose issues with hot reload method registration.
/// </summary>
public class HotReloadRegistryTests
{
[SetUp]
public void SetUp()
{
HotReloadRegistry.ClearAllForTesting();
}
[TearDown]
public void TearDown()
{
HotReloadRegistry.ClearAllForTesting();
}
[Test]
public void RegisterMethodOverride_DirectCall_UpdatesStats()
{
// Test direct registration to verify registry behavior
var method = typeof(TestRegistryClass).GetMethod("TestMethod");
var attribute = method.GetCustomAttribute<HotReloadOverrideMethodAttribute>();
var type = typeof(TestRegistryClass);
Assert.IsNotNull(method, "Test method should exist");
Assert.IsNotNull(attribute, "Test attribute should exist");
// Get initial stats
var initialStats = HotReloadRegistry.GetStats();
// CRITICAL: First register the target method as reloadable (this was missing!)
// Create a mock target method that matches the attribute's TargetMethodId
var targetMethod = typeof(MockTargetClass1).GetMethod("TargetMethod1");
var reloadableAttr = new HotReloadWithOverridesAttribute { Id = "TargetClass1.TargetMethod1" };
HotReloadRegistry.RegisterReloadableMethod(targetMethod, reloadableAttr);
// Register the type and method override
HotReloadRegistry.RegisterHotReloadType(type, "TestAssembly");
HotReloadRegistry.RegisterMethodOverride(method, attribute, type);
// Get final stats
var finalStats = HotReloadRegistry.GetStats();
// Verify stats changed
Assert.Greater(finalStats.ActiveOverrideCount, initialStats.ActiveOverrideCount, "Active override count should increase");
Assert.Greater(finalStats.LoadedTypeCount, initialStats.LoadedTypeCount, "Loaded type count should increase");
Assert.Greater(finalStats.ReloadableMethodCount, initialStats.ReloadableMethodCount, "Reloadable method count should increase");
}
[Test]
public void GetStats_AfterRegistration_ReturnsCorrectCounts()
{
// Test that GetStats returns accurate counts
var stats1 = HotReloadRegistry.GetStats();
Assert.AreEqual(0, stats1.ActiveOverrideCount, "Should start with 0 active overrides");
// Register target methods as reloadable first (this was missing!)
var targetMethod1 = typeof(MockTargetClass1).GetMethod("TargetMethod1");
var reloadableAttr1 = new HotReloadWithOverridesAttribute { Id = "TargetClass1.TargetMethod1" };
HotReloadRegistry.RegisterReloadableMethod(targetMethod1, reloadableAttr1);
var targetMethod2 = typeof(MockTargetClass2).GetMethod("TargetMethod2");
var reloadableAttr2 = new HotReloadWithOverridesAttribute { Id = "TargetClass2.TargetMethod2" };
HotReloadRegistry.RegisterReloadableMethod(targetMethod2, reloadableAttr2);
// Register multiple methods
var method1 = typeof(TestRegistryClass).GetMethod("TestMethod");
var attribute1 = method1.GetCustomAttribute<HotReloadOverrideMethodAttribute>();
HotReloadRegistry.RegisterHotReloadType(typeof(TestRegistryClass), "TestAssembly1");
HotReloadRegistry.RegisterMethodOverride(method1, attribute1, typeof(TestRegistryClass));
var method2 = typeof(TestRegistryClass2).GetMethod("AnotherTestMethod");
var attribute2 = method2.GetCustomAttribute<HotReloadOverrideMethodAttribute>();
HotReloadRegistry.RegisterHotReloadType(typeof(TestRegistryClass2), "TestAssembly2");
HotReloadRegistry.RegisterMethodOverride(method2, attribute2, typeof(TestRegistryClass2));
var finalStats = HotReloadRegistry.GetStats();
Assert.AreEqual(2, finalStats.ActiveOverrideCount, "Should have 2 active overrides");
Assert.AreEqual(2, finalStats.LoadedTypeCount, "Should have 2 loaded types");
Assert.AreEqual(2, finalStats.ReloadableMethodCount, "Should have 2 reloadable methods");
}
[Test]
public void ClearAllForTesting_ResetsStats()
{
// Register target method as reloadable first (this was missing!)
var targetMethod = typeof(MockTargetClass1).GetMethod("TargetMethod1");
var reloadableAttr = new HotReloadWithOverridesAttribute { Id = "TargetClass1.TargetMethod1" };
HotReloadRegistry.RegisterReloadableMethod(targetMethod, reloadableAttr);
// Register something first
var method = typeof(TestRegistryClass).GetMethod("TestMethod");
var attribute = method.GetCustomAttribute<HotReloadOverrideMethodAttribute>();
HotReloadRegistry.RegisterHotReloadType(typeof(TestRegistryClass), "TestAssembly");
HotReloadRegistry.RegisterMethodOverride(method, attribute, typeof(TestRegistryClass));
var statsBeforeClear = HotReloadRegistry.GetStats();
Assert.Greater(statsBeforeClear.ActiveOverrideCount, 0, "Should have active overrides before clear");
Assert.Greater(statsBeforeClear.ReloadableMethodCount, 0, "Should have reloadable methods before clear");
// Clear and verify
HotReloadRegistry.ClearAllForTesting();
var statsAfterClear = HotReloadRegistry.GetStats();
Assert.AreEqual(0, statsAfterClear.ActiveOverrideCount, "Should have 0 active overrides after clear");
Assert.AreEqual(0, statsAfterClear.LoadedTypeCount, "Should have 0 loaded types after clear");
Assert.AreEqual(0, statsAfterClear.ReloadableMethodCount, "Should have 0 reloadable methods after clear");
}
/// <summary>
/// Test class with HotReloadMethod attribute for registry testing.
/// </summary>
public static class TestRegistryClass
{
[HotReloadOverrideMethod("TargetClass1.TargetMethod1")]
public static void TestMethod(MockTargetClass1 instance)
{
}
}
/// <summary>
/// Second test class for multi-registration testing.
/// </summary>
public static class TestRegistryClass2
{
[HotReloadOverrideMethod("TargetClass2.TargetMethod2")]
public static void AnotherTestMethod(MockTargetClass2 instance)
{
}
}
/// <summary>
/// Mock target class 1 for testing reloadable method registration.
/// </summary>
public class MockTargetClass1
{
public int value = 42;
[HotReloadWithOverrides(Id = "TargetClass1.TargetMethod1")]
public void TargetMethod1()
{
value = 123;
}
}
/// <summary>
/// Mock target class 2 for testing reloadable method registration.
/// </summary>
public class MockTargetClass2
{
public string text = "original";
[HotReloadWithOverrides(Id = "TargetClass2.TargetMethod2")]
public void TargetMethod2()
{
text = "modified";
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 522caacf8c1467a44b3f4f93157f373a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,145 @@
using System;
using System.Reflection;
using NUnit.Framework;
using Unity.Pipeline.HotReload;
using UnityEngine;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Simple verification tests for hot reload infrastructure.
/// These tests verify the core functionality works correctly in isolation.
/// </summary>
public class HotReloadVerificationTests
{
[SetUp]
public void SetUp()
{
// Ensure clean state for each test
HotReloadRegistry.ClearAllForTesting();
}
[TearDown]
public void TearDown()
{
// Clean up after each test
HotReloadRegistry.ClearAllForTesting();
}
[Test]
public void HotReloadRegistry_EmptyState_HasZeroStats()
{
// Act
var stats = HotReloadRegistry.GetStats();
// Assert
Assert.That(stats.ReloadableMethodCount, Is.EqualTo(0));
Assert.That(stats.ActiveOverrideCount, Is.EqualTo(0));
Assert.That(stats.LoadedTypeCount, Is.EqualTo(0));
}
[Test]
public void HotReloadRegistry_RegisterSingleMethod_CorrectCount()
{
// Arrange
var testType = typeof(SimpleTestClass);
var testMethod = testType.GetMethod("TestMethod");
var attribute = new HotReloadWithOverridesAttribute();
// Act
HotReloadRegistry.RegisterReloadableMethod(testMethod, attribute);
// Assert
var stats = HotReloadRegistry.GetStats();
Assert.That(stats.ReloadableMethodCount, Is.EqualTo(1));
Assert.That(stats.ReloadableMethodIds.Count, Is.EqualTo(1));
Assert.That(stats.ReloadableMethodIds.Contains("SimpleTestClass.TestMethod"));
}
[Test]
public void HotReloadRegistry_RegisterMultipleMethods_CorrectCount()
{
// Arrange
var testType = typeof(SimpleTestClass);
var method1 = testType.GetMethod("TestMethod");
var method2 = testType.GetMethod("AnotherTestMethod");
// Act
HotReloadRegistry.RegisterReloadableMethod(method1, new HotReloadWithOverridesAttribute());
HotReloadRegistry.RegisterReloadableMethod(method2, new HotReloadWithOverridesAttribute { Id = "CustomId" });
// Assert
var stats = HotReloadRegistry.GetStats();
Assert.That(stats.ReloadableMethodCount, Is.EqualTo(2));
Assert.That(stats.ReloadableMethodIds.Count, Is.EqualTo(2));
Assert.That(stats.ReloadableMethodIds.Contains("SimpleTestClass.TestMethod"));
Assert.That(stats.ReloadableMethodIds.Contains("CustomId"));
}
[Test]
public void HotReloadRegistry_TryInvokeWithoutOverride_ReturnsFalse()
{
// Arrange
var testType = typeof(SimpleTestClass);
var testMethod = testType.GetMethod("TestMethod");
HotReloadRegistry.RegisterReloadableMethod(testMethod, new HotReloadWithOverridesAttribute());
var testInstance = new SimpleTestClass();
// Act
var result = HotReloadRegistry.TryInvokeHotReload("SimpleTestClass.TestMethod", testInstance);
// Assert
Assert.That(result, Is.False);
}
[Test]
public void HotReloadHelper_GenerateMethodId_CorrectFormat()
{
// This tests the private method indirectly through the IsHotReloadActive method
// Act & Assert
Assert.That(HotReloadHelper.IsHotReloadActive<SimpleTestClass>("TestMethod"), Is.False);
// The method ID generation is tested implicitly - if it's wrong, the registry lookup would fail
}
[Test]
public void HotReloadRegistry_ClearForTesting_RemovesAllState()
{
// Arrange - add some state
var testType = typeof(SimpleTestClass);
var testMethod = testType.GetMethod("TestMethod");
HotReloadRegistry.RegisterReloadableMethod(testMethod, new HotReloadWithOverridesAttribute());
// Verify state exists
var statsBefore = HotReloadRegistry.GetStats();
Assert.That(statsBefore.ReloadableMethodCount, Is.EqualTo(1));
// Act
HotReloadRegistry.ClearAllForTesting();
// Assert
var statsAfter = HotReloadRegistry.GetStats();
Assert.That(statsAfter.ReloadableMethodCount, Is.EqualTo(0));
Assert.That(statsAfter.ActiveOverrideCount, Is.EqualTo(0));
Assert.That(statsAfter.LoadedTypeCount, Is.EqualTo(0));
}
/// <summary>
/// Simple test class for hot reload testing.
/// </summary>
public class SimpleTestClass
{
public int value;
public void TestMethod()
{
value = 42;
}
public int AnotherTestMethod(int input)
{
return input * 2;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d32ede884c994bb4b9f1b1bf6053697a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,425 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using NUnit.Framework;
using Unity.Pipeline;
using Unity.Pipeline.Models;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor;
using Unity.Pipeline.Editor.Commands;
using Unity.Pipeline.Security;
using Unity.Pipeline.Tests.Runtime;
using Unity.Pipeline.Threading;
using UnityEngine;
using UnityEngine.TestTools;
using Newtonsoft.Json;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Tests for CLI instance discovery logic.
/// These test the file-based discovery mechanism that CLI tools will use to find running Editor instances.
/// </summary>
public class InstanceDiscoveryTests
{
// Non-null only when this suite started the server itself (no live server was running).
private EditorPipelineServer m_OwnedServer;
// Port of the live (preferred) or owned server whose descriptor we test against.
private int m_ServerPort;
// Auth token of that server (from the descriptor for a live server, or the session token).
private string m_ServerToken;
private string m_TestProjectPath;
[SetUp]
public void SetUp()
{
// Setup command discovery
CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
// Use current project path for testing
m_TestProjectPath = Path.GetDirectoryName(Application.dataPath);
// Discovery is a read-only mechanism over the shared .unity-pipeline-port descriptor.
// PREFER the already-running live server and NEVER disturb it: if a live server is
// advertising a descriptor, test against that (read-only). Only when none is running do
// we start our own descriptor-writing server, remembering we own it so TearDown stops
// ONLY what we started — it must never stop or delete the live server's descriptor.
var existing = InstanceDescriptor.ReadFromProjectRoot(m_TestProjectPath);
if (existing != null && existing.Port > 0 && MockCliDiscovery.IsInstanceRunning(existing))
{
m_ServerPort = existing.Port;
m_ServerToken = existing.EvalToken;
}
else
{
m_OwnedServer = new EditorPipelineServer();
m_OwnedServer.Start();
m_ServerPort = m_OwnedServer.Port;
m_ServerToken = SecurityTokenManager.GetOrCreateToken();
}
}
[TearDown]
public void TearDown()
{
// Never stop the live server — only a server this suite started itself.
m_OwnedServer?.Stop();
m_OwnedServer = null;
}
[Test]
public void InstanceDiscovery_ReadDescriptorFile_ParsesCorrectly()
{
// Arrange - Server should have created descriptor file during startup
var descriptorPath = InstanceDescriptor.GetDescriptorFilePath(m_TestProjectPath);
// Act - Read descriptor using CLI discovery logic
var instance = MockCliDiscovery.ReadInstanceDescriptor(descriptorPath);
// Assert - Should successfully parse instance information
Assert.IsNotNull(instance, "Should be able to read instance descriptor");
Assert.AreEqual(m_ServerPort, instance.Port, "Port should match running server");
Assert.Greater(instance.Pid, 0, "Should have valid process ID");
Assert.AreEqual(m_TestProjectPath, instance.ProjectPath, "Project path should match");
Assert.IsNotEmpty(instance.ProjectName, "Project name should not be empty");
Assert.IsNotEmpty(instance.UnityVersion, "Unity version should not be empty");
}
[Test]
public void InstanceDiscovery_ScanDirectory_FindsRunningInstances()
{
// Arrange - We have a running server in the test project
var searchPaths = new[] { m_TestProjectPath };
// Act - Discover instances using CLI discovery logic
var instances = MockCliDiscovery.DiscoverInstances(searchPaths);
// Assert - Should find our test instance
Assert.Greater(instances.Count(), 0, "Should find at least one running instance");
var testInstance = instances.FirstOrDefault(i => i.Port == m_ServerPort);
Assert.IsNotNull(testInstance, "Should find our test server instance");
Assert.IsTrue(testInstance.IsRunning, "Instance should be marked as running");
}
[Test]
public async Task Server_BasicStatusEndpoint_WorksWithoutEditorProvider()
{
// Arrange - Start server WITHOUT EditorStatusProviderImpl (uses fallback)
var testServer = new TestEditorPipelineServer();
// Don't set status provider - should use default implementation
testServer.Start();
try
{
// Give server time to fully start
await Task.Delay(200);
// Act - Test status endpoint with default provider
using (var client = new PipelineClient(testServer))
{
var response = await client.GetAsync("/api/status");
// Assert
Assert.IsTrue(response.IsSuccess,
$"Basic status endpoint should work. Status: {response.StatusCode}, Content: {response.RawResponse}");
// Verify it's basic server status (no Editor APIs)
Assert.AreEqual("ready", response.JsonResponse["status"]?.ToString(), "Basic status should return 'ready'");
}
}
finally
{
testServer?.Stop();
}
}
[Test]
public async Task Server_BasicCommandsEndpoint_WorksWithoutEditorProvider()
{
// Arrange - Start server WITHOUT EditorStatusProviderImpl
var testServer = new TestEditorPipelineServer();
testServer.Start();
try
{
// Act - Test commands endpoint
using (var client = new PipelineClient(testServer))
{
var response = await client.GetAsync("/api/commands");
// Assert
Assert.IsTrue(response.IsSuccess,
$"Basic commands endpoint should work. Status: {response.StatusCode}, Content: {response.RawResponse}");
}
}
finally
{
testServer?.Stop();
}
}
[Test]
public void Server_PortAssignment_WorksCorrectly()
{
// Arrange
var testServer = new TestEditorPipelineServer();
try
{
// Act
testServer.Start();
// Assert
Assert.IsTrue(testServer.IsRunning, "Server should be running");
Assert.Greater(testServer.Port, 0, "Server should have a valid port");
Assert.GreaterOrEqual(testServer.Port, 7800, "Port should be in valid range");
Assert.LessOrEqual(testServer.Port, 7899, "Port should be in valid range");
}
finally
{
testServer?.Stop();
}
}
[Test]
public void Dispatcher_BasicOperation_WorksCorrectly()
{
// A freshly initialized dispatcher runs Invoke synchronously on the calling (main) thread.
var dispatcher = new Dispatcher();
dispatcher.Initialize();
try
{
var result = dispatcher.Invoke(() => "test_result", 1000);
Assert.AreEqual("test_result", result, "Dispatcher should execute and return result");
}
finally
{
dispatcher.Shutdown();
}
}
[Test]
public void EditorStatusCommand_DirectCall_WorksOnMainThread()
{
// This test ensures the editor_status command works correctly when called directly from main thread
// Act - Direct call to the editor_status command (should work since Unity tests run on main thread)
var status = Unity.Pipeline.Editor.Commands.EditorStatusCommand.GetEditorStatus();
// Assert
Assert.IsNotNull(status, "Status should not be null");
Assert.IsNotNull(status.Status, "Status.Status should not be null");
Assert.IsNotNull(status.UnityVersion, "Status.UnityVersion should not be null");
Assert.IsNotNull(status.ProjectPath, "Status.ProjectPath should not be null");
}
[Test]
public void EditorStatusCommand_Discovery_VerifyAvailable()
{
// Arrange & Act - Check if editor_status command is discoverable
var commands = CommandRegistry.DiscoverCommands().ToList();
// Assert - editor_status command should be found
var editorStatusCommand = commands.FirstOrDefault(c => c.Name == "editor_status");
Assert.IsNotNull(editorStatusCommand, "editor_status command should be discoverable");
// Also verify we can invoke it via reflection (same path as server execution)
var result = editorStatusCommand.Method.Invoke(null, new object[0]);
Assert.IsNotNull(result, "Command execution via reflection should return a result");
}
[Test]
public async Task EditorStatusEndpoint_GetDetailedEditorInfo_RespondsCorrectly()
{
// Arrange - First verify the command is available
var commands = CommandRegistry.DiscoverCommands().ToList();
var editorStatusCommand = commands.FirstOrDefault(c => c.Name == "editor_status");
if (editorStatusCommand == null)
{
Assert.Fail($"editor_status command not found. Available commands: [{string.Join(", ", commands.Select(c => c.Name))}]");
}
// Give dispatcher time to initialize
await Task.Delay(200);
// Act - Make HTTP request to /api/editor_status (executes command via main thread marshaling)
using (var client = new PipelineClient($"http://localhost:{m_ServerPort}", m_ServerToken))
{
var response = await client.GetAsync("/api/editor_status");
Assert.IsTrue(response.IsSuccess,
$"Editor status endpoint should work. Status: {response.StatusCode}, Content: {response.RawResponse}");
// Verify it has Editor-specific data (from editor_status command)
var statusJson = response.JsonResponse;
Assert.IsNotNull(statusJson["status"], "Should include overall status");
Assert.IsNotNull(statusJson["compiling"], "Should include compilation state");
Assert.IsNotNull(statusJson["playMode"], "Should include play mode state");
Assert.IsNotNull(statusJson["unityVersion"], "Should include Unity version");
// Verify it's rich Editor data, not basic server data
Assert.Contains(statusJson["status"]?.ToString(), new[] { "ready", "compiling", "playing", "reloading" });
}
}
[Test]
public async Task InstanceDiscovery_ValidateConnection_ConfirmsServerReachable()
{
// Arrange - Get our test instance
var descriptorPath = InstanceDescriptor.GetDescriptorFilePath(m_TestProjectPath);
var instance = MockCliDiscovery.ReadInstanceDescriptor(descriptorPath);
Assert.IsNotNull(instance, "Should be able to read instance descriptor");
// Give server a moment to fully start if needed
await Task.Delay(100);
// Act - Validate connection to the instance
var isValid = await MockCliDiscovery.ValidateConnection(instance);
// Assert - Connection should be valid
Assert.IsTrue(isValid, "Should be able to connect to running server");
}
[Test]
public void InstanceDiscovery_StaleDescriptor_DetectedAsNotRunning()
{
// Arrange - Create a fake descriptor with non-existent PID
var staleDescriptor = new InstanceDescriptor
{
Pid = 99999, // Very unlikely to exist
Port = 9999,
ProjectPath = m_TestProjectPath,
ProjectName = "TestProject",
UnityVersion = Application.unityVersion,
Mode = "editor",
StartedAt = DateTime.UtcNow.AddHours(-1),
LastHeartbeat = DateTime.UtcNow.AddMinutes(-10)
};
// Act - Check if this represents a running instance
var isRunning = MockCliDiscovery.IsInstanceRunning(staleDescriptor);
// Assert - Should be detected as not running
Assert.IsFalse(isRunning, "Stale descriptor should be detected as not running");
}
}
/// <summary>
/// Mock implementation of CLI discovery logic.
/// Simulates what the real unity-cli will do for instance discovery and connection validation.
/// </summary>
public static class MockCliDiscovery
{
/// <summary>
/// Read instance descriptor from a .unity-pipeline-port file.
/// Simulates CLI reading discovery files.
/// </summary>
public static InstanceDescriptor ReadInstanceDescriptor(string filePath)
{
if (!File.Exists(filePath))
return null;
try
{
var json = File.ReadAllText(filePath);
return JsonConvert.DeserializeObject<InstanceDescriptor>(json);
}
catch
{
return null;
}
}
/// <summary>
/// Discover all running Pipeline instances in specified directories.
/// Simulates CLI scanning for .unity-pipeline-port files.
/// </summary>
public static IEnumerable<DiscoveredInstance> DiscoverInstances(IEnumerable<string> searchPaths)
{
var instances = new List<DiscoveredInstance>();
foreach (var path in searchPaths)
{
if (!Directory.Exists(path))
continue;
var descriptorPath = InstanceDescriptor.GetDescriptorFilePath(path);
var descriptor = ReadInstanceDescriptor(descriptorPath);
if (descriptor != null)
{
instances.Add(new DiscoveredInstance
{
Descriptor = descriptor,
IsRunning = IsInstanceRunning(descriptor),
DescriptorPath = descriptorPath
});
}
}
return instances;
}
/// <summary>
/// Check if an instance is actually running by validating the PID.
/// Simulates CLI process validation logic.
/// </summary>
public static bool IsInstanceRunning(InstanceDescriptor descriptor)
{
if (descriptor?.Pid == null || descriptor.Pid <= 0)
return false;
try
{
var process = System.Diagnostics.Process.GetProcessById(descriptor.Pid);
return !process.HasExited;
}
catch
{
return false;
}
}
/// <summary>
/// Validate that an instance is reachable via HTTP.
/// Simulates CLI connection validation using basic /api/status endpoint.
/// </summary>
public static async Task<bool> ValidateConnection(InstanceDescriptor instance)
{
if (instance?.Port == null || instance.Port <= 0)
return false;
try
{
using (var client = new PipelineClient($"http://localhost:{instance.Port}", instance.EvalToken))
{
var response = await client.GetAsync("/api/status");
return response.IsSuccess && response.JsonResponse?["status"]?.ToString() == "ready";
}
}
catch
{
return false;
}
}
}
/// <summary>
/// Represents a discovered Pipeline instance from CLI perspective.
/// Contains both the descriptor data and validation status.
/// </summary>
public class DiscoveredInstance
{
public InstanceDescriptor Descriptor { get; set; }
public bool IsRunning { get; set; }
public string DescriptorPath { get; set; }
// Convenience properties
public int Port => Descriptor?.Port ?? 0;
public string ProjectPath => Descriptor?.ProjectPath ?? string.Empty;
public string ProjectName => Descriptor?.ProjectName ?? string.Empty;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e3d589d8fc3c2d6428c7ebabab8993cf
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,111 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using NUnit.Framework;
using NUnit.Framework.Interfaces;
using Unity.Pipeline.Models;
using Unity.Pipeline.Tests.Runtime;
using UnityEngine;
// Apply the guard to every test in this (editor test) assembly.
[assembly: Unity.Pipeline.Tests.Editor.LiveServerGuard]
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Assembly-wide guard that runs after EVERY test in this assembly. If a live editor pipeline
/// server was advertising its descriptor (.unity-pipeline-port) before the test, this asserts
/// afterwards that the descriptor is still intact (present, same port) and the server is still
/// listening — failing, and naming, any test that deletes/clobbers the descriptor or kills the
/// listener. That turns "the whole dogfood run mysteriously dies" into a single named failure.
///
/// Temporary safety net until no test disturbs the live server. Known limitation: it cannot
/// detect a server whose command dispatcher is dead but whose listener still answers /api/status,
/// because exec-ing a real (main-thread) command from a teardown would deadlock.
/// </summary>
[AttributeUsage(AttributeTargets.Assembly)]
public sealed class LiveServerGuardAttribute : Attribute, ITestAction
{
private InstanceDescriptor m_Before;
public ActionTargets Targets => ActionTargets.Test;
public void BeforeTest(ITest test)
{
m_Before = InstanceDescriptor.ReadFromProjectRoot(ProjectRoot());
}
public void AfterTest(ITest test)
{
// Only protect a live server that already existed before this test ran. If none was
// running (e.g. CI, or a test that legitimately starts/stops its own), there's nothing
// to protect.
if (m_Before == null || m_Before.Port <= 0)
return;
var after = InstanceDescriptor.ReadFromProjectRoot(ProjectRoot());
// [Explicit] suites (ServerLifecycle, RuntimePipelineServerTests, …) are excluded from the
// dogfood loop and knowingly disturb the live server — but they MUST hand it back intact in
// their TearDown (GlobalServerStateGuard.Restore / PipelineServerStartup.RestartServer).
// We don't require the descriptor to be byte-identical (a restart may pick a fresh port),
// but the restore has to leave a live, reachable server. Asserting health here turns a
// broken self-restore — which otherwise wedges the whole session/dogfood loop with no clue
// who did it — into a single named failure. (AfterTest runs after [TearDown].)
if (IsExplicit(test))
{
Assert.IsNotNull(after,
$"'{test.Name}' ([Explicit]) left no live pipeline server descriptor after TearDown — its self-restore failed.");
Assert.IsTrue(IsListening(after.Port, after.EvalToken),
$"'{test.Name}' ([Explicit]) left the live pipeline server (port {after.Port}) not responding on /api/status after TearDown — its self-restore failed.");
return;
}
// Regular dogfood tests must not disturb the live server at all.
Assert.IsNotNull(after,
$"'{test.Name}' deleted the live pipeline server descriptor (.unity-pipeline-port) — it disturbed the global server.");
Assert.AreEqual(m_Before.Port, after.Port,
$"'{test.Name}' changed the live pipeline server descriptor port (was {m_Before.Port}, now {after.Port}) — it clobbered the global server.");
Assert.IsTrue(IsListening(after.Port, after.EvalToken),
$"'{test.Name}' left the live pipeline server (port {after.Port}) not responding on /api/status — it disturbed the global server.");
}
// True if the test or its declaring fixture is [Explicit] (checked at type level too, since
// Unity doesn't reliably propagate class-level [Explicit] to leaf RunState).
private static bool IsExplicit(ITest test)
{
if (test.RunState == RunState.Explicit)
return true;
var type = test.TypeInfo?.Type;
if (type != null && type.GetCustomAttributes(true).Any(a => a.GetType().Name == "ExplicitAttribute"))
return true;
var method = test.Method?.MethodInfo;
if (method != null && method.GetCustomAttributes(true).Any(a => a.GetType().Name == "ExplicitAttribute"))
return true;
return false;
}
private static bool IsListening(int port, string token)
{
try
{
// Run on the threadpool so this (main-thread) teardown can block on it without
// deadlocking: /api/status is served on the listener thread and needs no dispatcher.
// Every route now requires the bearer token, so authenticate with the descriptor's token.
var probe = Task.Run(async () =>
{
using (var client = new PipelineClient($"http://localhost:{port}", token))
return await client.GetStatusAsync();
});
return probe.Wait(3000) && probe.Result.IsSuccess;
}
catch
{
return false;
}
}
private static string ProjectRoot() => Path.GetDirectoryName(Application.dataPath);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a6c411c18ec52f144a0c6c0dedadf519
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,47 @@
using System.Net.Http;
using System.Net.Sockets;
using System.Threading.Tasks;
using NUnit.Framework;
using Unity.Pipeline.Security;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Verifies the server is reachable over the explicit IPv4 loopback (127.0.0.1), independent of
/// how "localhost" happens to resolve. This guards the intermittent connectivity failures caused
/// by "localhost" resolving to IPv6 (::1) on some hosts while the server bound only IPv4 (or vice
/// versa). Note: Unity's Mono HttpListener cannot serve requests that arrive over the IPv6
/// loopback (it mis-parses the "[::1]" host and returns 400), so IPv4 is the reliable channel.
/// </summary>
public class LoopbackBindingTests
{
private static int GetStatus(string url)
{
var task = Task.Run(async () =>
{
using (var client = new HttpClient())
using (var request = new HttpRequestMessage(HttpMethod.Get, url))
{
request.Headers.TryAddWithoutValidation(
"Authorization", $"Bearer {SecurityTokenManager.GetOrCreateToken()}");
var response = await client.SendAsync(request);
return (int)response.StatusCode;
}
});
return task.GetAwaiter().GetResult();
}
[Test]
public void Status_OverIPv4Loopback_Returns200()
{
if (!Socket.OSSupportsIPv4)
Assert.Ignore("IPv4 not available on this host.");
using (var server = new PipelineTestServer())
Assert.AreEqual(200, GetStatus($"http://127.0.0.1:{server.Port}/api/status"),
"Server should be reachable over the explicit IPv4 loopback.");
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8c462f11c3e0f2a41ad5827353bffb61
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 37da5c56100b4bf881a4907d63f35e3e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,374 @@
using System.IO;
using System.Linq;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Editor.Commands.Materials;
using Unity.Pipeline.Models;
using UnityEditor;
using UnityEngine;
namespace Unity.Pipeline.Tests.Editor.Materials
{
/// <summary>
/// Tests for the CLI-213 material authoring commands (get_material_properties /
/// set_material_properties). All assets are generated in-test under a temp folder and torn down
/// afterwards. URP-specific assertions (e.g. _BaseColor / _Metallic Range / _BaseMap) are guarded
/// so the suite still runs (with Assert.Ignore) on a Built-in RP project that lacks URP/Lit.
/// </summary>
public class MaterialCommandsTests
{
private const string Root = "Assets/__CLI213MaterialTest";
private string m_KnownShader; // a shader guaranteed to resolve on this project
private bool m_HasUrpLit;
[SetUp]
public void SetUp()
{
ProjectPaths.ResetAuthoringRoot();
m_HasUrpLit = Shader.Find("Universal Render Pipeline/Lit") != null;
m_KnownShader = PickKnownShader();
EnsureFolder(Root);
}
[TearDown]
public void TearDown()
{
ProjectPaths.ResetAuthoringRoot();
if (AssetDatabase.IsValidFolder(Root))
{
AssetDatabase.DeleteAsset(Root);
AssetDatabase.Refresh();
}
}
private static string PickKnownShader()
{
if (Shader.Find("Standard") != null)
return "Standard";
if (Shader.Find("Universal Render Pipeline/Lit") != null)
return "Universal Render Pipeline/Lit";
return "Sprites/Default";
}
/// <summary>The Color property to exercise: URP/Lit uses _BaseColor; built-in Standard uses _Color.</summary>
private string ColorProp => m_HasUrpLit ? "_BaseColor" : "_Color";
private static void EnsureFolder(string folder)
{
if (!AssetDatabase.IsValidFolder(folder))
AssetDatabase.CreateFolder("Assets", Path.GetFileName(folder));
}
/// <summary>Create a .mat asset with the given shader and return a path-based handle to it.</summary>
private ObjectRef CreateMaterial(string name, string shaderName = null)
{
var shader = Shader.Find(shaderName ?? m_KnownShader);
Assert.IsNotNull(shader, $"Test prerequisite: shader '{shaderName ?? m_KnownShader}' must resolve");
var mat = new Material(shader);
var path = $"{Root}/{name}.mat";
AssetDatabase.CreateAsset(mat, path);
AssetDatabase.SaveAssets();
return new ObjectRef { Path = path };
}
/// <summary>Create a tiny 2x2 texture asset on disk and return a handle to it.</summary>
private ObjectRef CreateTexture(string name)
{
var tex = new Texture2D(2, 2);
tex.SetPixels(new[] { Color.white, Color.white, Color.white, Color.white });
tex.Apply();
var bytes = tex.EncodeToPNG();
Object.DestroyImmediate(tex);
var path = $"{Root}/{name}.png";
File.WriteAllBytes(Path.Combine(ProjectPaths.ProjectRoot, path), bytes);
AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate);
return new ObjectRef { Path = path };
}
private static MaterialPropertyValue Prop(MaterialPropertiesResult r, string name)
=> r.Properties.FirstOrDefault(p => p.Name == name);
/// <summary>
/// Extract the float components of a Color/Vector property value. The direct (in-process) path
/// returns a <c>float[]</c>; tolerate any IEnumerable (e.g. a JArray) so the helper also works
/// if the value was JSON round-tripped.
/// </summary>
private static float[] Components(object value)
{
Assert.IsNotNull(value, "property value should not be null");
return ((System.Collections.IEnumerable)value)
.Cast<object>()
.Select(o => System.Convert.ToSingle(o is JToken t ? (object)t.ToObject<float>() : o))
.ToArray();
}
// ---- get_material_properties --------------------------------------------------------------
[Test]
public void Get_ReturnsShaderRenderQueueKeywordsAndProperties()
{
var handle = CreateMaterial("Get");
var result = MaterialCommands.GetMaterialProperties(handle);
Assert.AreEqual(Shader.Find(m_KnownShader).name, result.Shader);
Assert.IsNotNull(result.EnabledKeywords);
Assert.IsNotEmpty(result.Properties, "A real shader should expose at least one property");
var color = Prop(result, ColorProp);
Assert.IsNotNull(color, $"{ColorProp} should be present");
Assert.AreEqual("Color", color.Type);
}
[Test]
public void Get_Urp_BaseColorIsColor_MetallicIsRangeWithLimits()
{
if (!m_HasUrpLit)
Assert.Ignore("URP/Lit not available in this project; skipping URP-specific assertion (AC #1).");
var handle = CreateMaterial("UrpGet", "Universal Render Pipeline/Lit");
var result = MaterialCommands.GetMaterialProperties(handle);
var baseColor = Prop(result, "_BaseColor");
Assert.IsNotNull(baseColor, "_BaseColor should be present");
Assert.AreEqual("Color", baseColor.Type);
var metallic = Prop(result, "_Metallic");
Assert.IsNotNull(metallic, "_Metallic should be present");
Assert.AreEqual("Range", metallic.Type);
Assert.IsNotNull(metallic.Range, "_Metallic (Range) should carry range limits");
Assert.LessOrEqual(metallic.Range.Min, metallic.Range.Max);
}
// ---- set_material_properties: color -------------------------------------------------------
[Test]
public void Set_BaseColor_ViaArray_AppliesRed()
{
var handle = CreateMaterial("SetColorArray");
var props = new JObject { [ColorProp] = new JArray(1, 0, 0, 1) };
var result = MaterialCommands.SetMaterialProperties(handle, properties: props);
CollectionAssert.Contains(result.Applied, ColorProp);
var read = MaterialCommands.GetMaterialProperties(handle);
var c = Components(Prop(read, ColorProp).Value);
Assert.AreEqual(1f, c[0], 1e-4f);
Assert.AreEqual(0f, c[1], 1e-4f);
Assert.AreEqual(0f, c[2], 1e-4f);
Assert.AreEqual(1f, c[3], 1e-4f);
}
[Test]
public void Set_BaseColor_ViaHex_AppliesGreen()
{
var handle = CreateMaterial("SetColorHex");
var props = new JObject { [ColorProp] = "#00FF00" };
MaterialCommands.SetMaterialProperties(handle, properties: props);
var read = MaterialCommands.GetMaterialProperties(handle);
var c = Components(Prop(read, ColorProp).Value);
Assert.AreEqual(0f, c[0], 1e-3f);
Assert.AreEqual(1f, c[1], 1e-3f);
Assert.AreEqual(0f, c[2], 1e-3f);
Assert.AreEqual(1f, c[3], 1e-3f, "hex without alpha should default alpha to 1.0");
}
// ---- set_material_properties: texture -----------------------------------------------------
[Test]
public void Set_Texture_AssignsThenClears()
{
if (!m_HasUrpLit)
Assert.Ignore("URP/Lit not available; skipping _BaseMap texture test (AC #4).");
var handle = CreateMaterial("SetTex", "Universal Render Pipeline/Lit");
var tex = CreateTexture("Albedo");
var set = MaterialCommands.SetMaterialProperties(handle,
properties: new JObject { ["_BaseMap"] = JToken.FromObject(tex) });
CollectionAssert.Contains(set.Applied, "_BaseMap");
var loadedTex = AssetDatabase.LoadAssetAtPath<Texture>(tex.Path);
var mat = AssetDatabase.LoadAssetAtPath<Material>(handle.Path);
Assert.AreEqual(loadedTex, mat.GetTexture("_BaseMap"), "texture should be assigned");
// Clear with null.
MaterialCommands.SetMaterialProperties(handle,
properties: new JObject { ["_BaseMap"] = JValue.CreateNull() });
mat = AssetDatabase.LoadAssetAtPath<Material>(handle.Path);
Assert.IsNull(mat.GetTexture("_BaseMap"), "passing null should clear the texture");
}
[Test]
public void Set_Texture_OutsideAuthoringRoot_IsRejected()
{
if (!m_HasUrpLit)
Assert.Ignore("URP/Lit not available; skipping _BaseMap sandbox test (AC #13).");
// Confine the authoring root to a subfolder, then reference a texture created OUTSIDE it.
var outsideTex = CreateTexture("OutsideAlbedo"); // lives directly under Root
var sub = $"{Root}/Sub";
if (!AssetDatabase.IsValidFolder(sub))
AssetDatabase.CreateFolder(Root, "Sub");
var handle = CreateMaterial("Sub/SetTexSandbox", "Universal Render Pipeline/Lit");
ProjectPaths.AuthoringRoot = sub; // now Root/OutsideAlbedo.png is outside the root
var ex = Assert.Throws<System.ArgumentException>(() =>
MaterialCommands.SetMaterialProperties(handle,
properties: new JObject { ["_BaseMap"] = JToken.FromObject(outsideTex) }));
StringAssert.Contains("outside the authoring root", ex.Message);
}
// ---- shader reassignment ------------------------------------------------------------------
[Test]
public void Set_Shader_Reassigns()
{
if (Shader.Find("Standard") == null)
Assert.Ignore("Built-in Standard shader not available; skipping shader-reassign test (AC #5).");
var handle = CreateMaterial("Reassign", m_KnownShader);
var result = MaterialCommands.SetMaterialProperties(handle, shader: "Standard");
Assert.AreEqual("Standard", result.Shader);
var read = MaterialCommands.GetMaterialProperties(handle);
Assert.AreEqual("Standard", read.Shader);
}
[Test]
public void Set_Shader_NotFound_ThrowsAndWritesNothing()
{
var handle = CreateMaterial("BadShader");
var before = MaterialCommands.GetMaterialProperties(handle).Shader;
var ex = Assert.Throws<ShaderNotFoundException>(() =>
MaterialCommands.SetMaterialProperties(handle, shader: "No/Such/Shader__CLI213",
properties: new JObject { [ColorProp] = new JArray(1, 0, 0, 1) }));
StringAssert.Contains("shader_not_found", ex.Message);
Assert.AreEqual("shader_not_found", ex.Code);
var after = MaterialCommands.GetMaterialProperties(handle).Shader;
Assert.AreEqual(before, after, "a shader_not_found must write nothing");
}
// ---- keywords -----------------------------------------------------------------------------
[Test]
public void Set_Keyword_EnableThenDisable()
{
var handle = CreateMaterial("Keyword");
const string keyword = "_EMISSION";
MaterialCommands.SetMaterialProperties(handle, enableKeywords: new[] { keyword });
var enabled = MaterialCommands.GetMaterialProperties(handle).EnabledKeywords;
CollectionAssert.Contains(enabled, keyword);
MaterialCommands.SetMaterialProperties(handle, disableKeywords: new[] { keyword });
var disabled = MaterialCommands.GetMaterialProperties(handle).EnabledKeywords;
CollectionAssert.DoesNotContain(disabled, keyword);
}
// ---- render queue -------------------------------------------------------------------------
[Test]
public void Set_RenderQueue_ExplicitThenInherit()
{
var handle = CreateMaterial("Queue");
MaterialCommands.SetMaterialProperties(handle, renderQueue: 3000);
Assert.AreEqual(3000, MaterialCommands.GetMaterialProperties(handle).RenderQueue,
"an explicit renderQueue must be reflected by the command's own read-back");
MaterialCommands.SetMaterialProperties(handle, renderQueue: -1);
// Validate inheritance via the command's raw queue read rather than the effective queue: the shader
// default can legitimately equal the prior override (e.g. 3000), so comparing the effective
// value would be flaky. The command returns -1 exactly when the material inherits the shader's.
Assert.AreEqual(-1, MaterialCommands.GetMaterialProperties(handle).RenderQueue,
"renderQueue -1 should clear the override and inherit from the shader");
}
// ---- unknown / mismatch -------------------------------------------------------------------
[Test]
public void Set_UnknownProperty_GoesToUnknown_StillAppliesOthers()
{
var handle = CreateMaterial("Unknown");
var props = new JObject
{
[ColorProp] = new JArray(0, 0, 1, 1),
["_DefinitelyNotARealProp_CLI213"] = 5,
};
var result = MaterialCommands.SetMaterialProperties(handle, properties: props);
CollectionAssert.Contains(result.Applied, ColorProp);
Assert.IsTrue(result.Unknown.Any(u => u.Contains("_DefinitelyNotARealProp_CLI213")),
"unknown property should be reported in unknown[]");
}
[Test]
public void Set_TypeMismatch_GoesToUnknownWithReason()
{
if (!m_HasUrpLit)
Assert.Ignore("URP/Lit not available; skipping _Metallic type-mismatch test.");
var handle = CreateMaterial("Mismatch", "Universal Render Pipeline/Lit");
// _Metallic is a Range (number); send an array to force a mismatch.
var props = new JObject
{
["_BaseColor"] = new JArray(1, 1, 1, 1),
["_Metallic"] = new JArray(1, 2, 3),
};
var result = MaterialCommands.SetMaterialProperties(handle, properties: props);
CollectionAssert.Contains(result.Applied, "_BaseColor");
Assert.IsTrue(result.Unknown.Any(u => u.Contains("_Metallic")),
"a type mismatch should be reported in unknown[] with a reason");
}
// ---- dry_run ------------------------------------------------------------------------------
[Test]
public void Set_DryRun_ReportsButDoesNotWrite()
{
var handle = CreateMaterial("DryRun");
var beforeColor = Components(Prop(MaterialCommands.GetMaterialProperties(handle), ColorProp).Value);
var props = new JObject { [ColorProp] = new JArray(1, 0, 0, 1) };
var result = MaterialCommands.SetMaterialProperties(handle, properties: props, dryRun: true);
CollectionAssert.Contains(result.Applied, ColorProp);
var afterColor = Components(Prop(MaterialCommands.GetMaterialProperties(handle), ColorProp).Value);
CollectionAssert.AreEqual(beforeColor, afterColor, "dry_run must not change the material");
}
// ---- ViaClient ----------------------------------------------------------------------------
[Test]
public void Set_ViaClient_AppliesColor()
{
var handle = CreateMaterial("ViaClient");
using (var server = new PipelineTestServer())
{
var response = server.Execute("set_material_properties", new
{
material = new { path = handle.Path },
properties = new JObject { [ColorProp] = new JArray(1, 0, 0, 1) },
});
Assert.IsTrue(response.IsSuccess, $"set_material_properties should succeed: {response.Error}");
var read = MaterialCommands.GetMaterialProperties(handle);
var c = Components(Prop(read, ColorProp).Value);
Assert.AreEqual(1f, c[0], 1e-4f);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a96b5f4084674e609bbfbb12e73bde08
@@ -0,0 +1,177 @@
using System.Linq;
using NUnit.Framework;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Editor.Commands.Materials;
using Unity.Pipeline.Models;
using UnityEditor;
using UnityEngine;
namespace Unity.Pipeline.Tests.Editor.Materials
{
/// <summary>
/// Tests for the CLI-213 shader discovery / introspection commands (list_shaders /
/// get_shader_properties). URP-specific assertions are guarded with Assert.Ignore so the suite runs
/// on a Built-in RP project too.
/// </summary>
public class ShaderCommandsTests
{
private bool m_HasUrpLit;
[SetUp]
public void SetUp()
{
ProjectPaths.ResetAuthoringRoot();
m_HasUrpLit = Shader.Find("Universal Render Pipeline/Lit") != null;
}
[TearDown]
public void TearDown() => ProjectPaths.ResetAuthoringRoot();
// ---- list_shaders -------------------------------------------------------------------------
[Test]
public void ListShaders_FilterLit_ReturnsTheLitShader()
{
var results = ShaderCommands.ListShaders(filter: "Lit");
Assert.IsNotEmpty(results, "filtering by 'Lit' should return at least one shader");
Assert.IsTrue(results.All(s => s.Name.IndexOf("Lit", System.StringComparison.OrdinalIgnoreCase) >= 0),
"every result should contain the filter substring");
// Only assert the specific shader name when URP/Lit is present; this branch never runs
// otherwise, so a plain constant keeps the intent clear.
if (m_HasUrpLit)
Assert.IsTrue(results.Any(s => s.Name == "Universal Render Pipeline/Lit"),
"the active pipeline's Lit shader should be listed (AC #10)");
}
[Test]
public void ListShaders_ReportsIsSupportedAndName()
{
var results = ShaderCommands.ListShaders(filter: "Lit", limit: 50);
if (results.Count == 0)
Assert.Ignore("No 'Lit' shaders in this project.");
foreach (var s in results)
Assert.IsFalse(string.IsNullOrEmpty(s.Name), "every shader entry must carry a name");
// isSupported is a bool that always serializes; just assert the field is meaningful for a known shader.
var known = results.FirstOrDefault();
Assert.IsNotNull(known);
}
[Test]
public void ListShaders_RespectsLimit()
{
var results = ShaderCommands.ListShaders(limit: 3);
Assert.LessOrEqual(results.Count, 3, "limit should cap the result count");
}
[Test]
public void ListShaders_ExcludeBuiltin_DropsEngineShaders()
{
var withBuiltin = ShaderCommands.ListShaders(includeBuiltin: true);
var withoutBuiltin = ShaderCommands.ListShaders(includeBuiltin: false);
Assert.IsFalse(withoutBuiltin.Any(s => s.IsBuiltin),
"includeBuiltin=false must not return any built-in shaders");
Assert.GreaterOrEqual(withBuiltin.Count, withoutBuiltin.Count);
}
// ---- get_shader_properties ----------------------------------------------------------------
[Test]
public void GetShaderProperties_Urp_ReturnsBaseColorMetallicBaseMap()
{
if (!m_HasUrpLit)
Assert.Ignore("URP/Lit not available; skipping URP property introspection (AC #11).");
var result = ShaderCommands.GetShaderProperties(shader: "Universal Render Pipeline/Lit");
Assert.AreEqual("Universal Render Pipeline/Lit", result.Shader);
var baseColor = result.Properties.FirstOrDefault(p => p.Name == "_BaseColor");
Assert.IsNotNull(baseColor, "_BaseColor should be present");
Assert.AreEqual("Color", baseColor.Type);
var metallic = result.Properties.FirstOrDefault(p => p.Name == "_Metallic");
Assert.IsNotNull(metallic, "_Metallic should be present");
Assert.AreEqual("Range", metallic.Type);
Assert.IsNotNull(metallic.Range, "_Metallic should carry min/max");
var baseMap = result.Properties.FirstOrDefault(p => p.Name == "_BaseMap");
Assert.IsNotNull(baseMap, "_BaseMap should be present");
Assert.AreEqual("TexEnv", baseMap.Type);
Assert.AreEqual("Tex2D", baseMap.TextureDimension);
}
[Test]
public void GetShaderProperties_KnownShader_ReturnsProperties()
{
// Shader-agnostic: any real shader exposes a non-empty property list with names + types.
var shaderName = m_HasUrpLit ? "Universal Render Pipeline/Lit"
: (Shader.Find("Standard") != null ? "Standard" : "Sprites/Default");
var result = ShaderCommands.GetShaderProperties(shader: shaderName);
Assert.AreEqual(shaderName, result.Shader);
Assert.IsNotEmpty(result.Properties);
foreach (var p in result.Properties)
{
Assert.IsFalse(string.IsNullOrEmpty(p.Name));
Assert.IsFalse(string.IsNullOrEmpty(p.Type));
Assert.IsNotNull(p.Flags, "flags should never be null (empty list when None)");
}
}
[Test]
public void GetShaderProperties_FromMaterial_ReadsShaderOffMaterial()
{
var shaderName = Shader.Find("Standard") != null ? "Standard"
: (m_HasUrpLit ? "Universal Render Pipeline/Lit" : "Sprites/Default");
var folder = "Assets/__CLI213ShaderTest";
if (!AssetDatabase.IsValidFolder(folder))
AssetDatabase.CreateFolder("Assets", "__CLI213ShaderTest");
try
{
var path = $"{folder}/Mat.mat";
AssetDatabase.CreateAsset(new Material(Shader.Find(shaderName)), path);
AssetDatabase.SaveAssets();
var result = ShaderCommands.GetShaderProperties(material: new ObjectRef { Path = path });
Assert.AreEqual(shaderName, result.Shader);
Assert.IsNotEmpty(result.Properties);
}
finally
{
AssetDatabase.DeleteAsset(folder);
AssetDatabase.Refresh();
}
}
[Test]
public void GetShaderProperties_NotFound_Throws()
{
var ex = Assert.Throws<ShaderNotFoundException>(() =>
ShaderCommands.GetShaderProperties(shader: "No/Such/Shader__CLI213"));
StringAssert.Contains("shader_not_found", ex.Message);
}
[Test]
public void GetShaderProperties_NeitherArg_Throws()
{
Assert.Throws<System.ArgumentException>(() => ShaderCommands.GetShaderProperties());
}
// ---- ViaClient ----------------------------------------------------------------------------
[Test]
public void ListShaders_ViaClient_Succeeds()
{
using (var server = new PipelineTestServer())
{
var response = server.Execute("list_shaders", new { filter = "Lit", limit = 20 });
Assert.IsTrue(response.IsSuccess, $"list_shaders should succeed: {response.Error}");
Assert.IsTrue(response.HasValidJson, "response should be valid JSON");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cd771dc30e5f4fbe9611c004593a09c4

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