Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,814 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using NUnit.Framework;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Unity.Pipeline;
|
||||
using Unity.Pipeline.Editor.Commands.Scripts;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor.Scripts
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the run_script command (AUTHAPI-26): single-file in-memory compile + named entry
|
||||
/// point execution, exercised directly and via PipelineClient. Covers the ticket's acceptance
|
||||
/// criteria: a valid entry runs and returns its result, compile errors return Roslyn diagnostics
|
||||
/// without executing, missing/ambiguous entry names error clearly, missing required params error,
|
||||
/// arg coercion (int/float/enum/string[]/ObjectRef), dry_run, defines, and hotpatch delegation.
|
||||
///
|
||||
/// The scripts are written to a temp folder OUTSIDE Assets/ on purpose: run_script must compile
|
||||
/// them in memory with no asset import and no domain reload.
|
||||
/// </summary>
|
||||
class RunScriptCommandTests
|
||||
{
|
||||
private readonly List<string> m_TempFiles = new List<string>();
|
||||
private readonly List<Object> m_Spawned = new List<Object>();
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
foreach (var path in m_TempFiles)
|
||||
{
|
||||
try { if (File.Exists(path)) File.Delete(path); } catch { /* best effort */ }
|
||||
}
|
||||
m_TempFiles.Clear();
|
||||
|
||||
foreach (var o in m_Spawned)
|
||||
if (o != null) Object.DestroyImmediate(o);
|
||||
m_Spawned.Clear();
|
||||
}
|
||||
|
||||
private string WriteTempScript(string source)
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "run_script_test_" + System.Guid.NewGuid().ToString("N") + ".cs");
|
||||
File.WriteAllText(path, source);
|
||||
m_TempFiles.Add(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
private GameObject TrackGo(GameObject go)
|
||||
{
|
||||
m_Spawned.Add(go);
|
||||
return go;
|
||||
}
|
||||
|
||||
#region Valid entry point
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_SinglePublicStatic_AutoDetectsAndReturnsResult()
|
||||
{
|
||||
var file = WriteTempScript(@"
|
||||
public static class Builder
|
||||
{
|
||||
public static int Build() { return 42; }
|
||||
}");
|
||||
var r = await RunScriptCommand.RunScript(file);
|
||||
|
||||
Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails);
|
||||
Assert.AreEqual(42, r.Result);
|
||||
// A warm Roslyn compile of a trivial file can complete in under 1ms — don't flake on it.
|
||||
Assert.GreaterOrEqual(r.CompileMs, 0, "compileMs should be recorded");
|
||||
Assert.IsNotNull(r.AssemblyName);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_ExplicitQualifiedEntry_Runs()
|
||||
{
|
||||
var file = WriteTempScript(@"
|
||||
namespace My.Space
|
||||
{
|
||||
public static class Tools
|
||||
{
|
||||
public static string Greet() { return ""hi""; }
|
||||
public static int Other() { return 1; }
|
||||
}
|
||||
}");
|
||||
var r = await RunScriptCommand.RunScript(file, entry: "My.Space.Tools.Greet");
|
||||
|
||||
Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails);
|
||||
Assert.AreEqual("hi", r.Result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_UnityApiInEntry_Works()
|
||||
{
|
||||
var file = WriteTempScript(@"
|
||||
using UnityEngine;
|
||||
public static class Builder
|
||||
{
|
||||
public static string Build() { return Application.unityVersion; }
|
||||
}");
|
||||
var r = await RunScriptCommand.RunScript(file);
|
||||
|
||||
Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails);
|
||||
Assert.IsInstanceOf<string>(r.Result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Compile errors and dry_run
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_CompileError_ReturnsDiagnostics_NothingExecutes()
|
||||
{
|
||||
var file = WriteTempScript(@"
|
||||
public static class Builder
|
||||
{
|
||||
public static int Build() { return 2 +; } // syntax error
|
||||
}");
|
||||
var r = await RunScriptCommand.RunScript(file);
|
||||
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.AreEqual("Compilation Failed", r.Error);
|
||||
Assert.Greater(r.Diagnostics.Count, 0, "should surface Roslyn diagnostics");
|
||||
Assert.IsNotEmpty(r.Diagnostics[0].Id, "diagnostic should carry a Roslyn id (e.g. CS1525)");
|
||||
Assert.AreEqual(0, r.ExecuteMs, "nothing should have executed");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_DryRun_CompilesWithoutExecuting()
|
||||
{
|
||||
var file = WriteTempScript(@"
|
||||
public static class Builder
|
||||
{
|
||||
public static int Build() { throw new System.Exception(""should not run""); }
|
||||
}");
|
||||
var r = await RunScriptCommand.RunScript(file, dryRun: true);
|
||||
|
||||
Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails);
|
||||
Assert.IsNull(r.Result, "dry_run must not execute the entry");
|
||||
Assert.AreEqual(0, r.ExecuteMs);
|
||||
// dry_run is emit-only: the compiled assembly is never loaded into the domain (Mono
|
||||
// can't unload it), so there is no assembly name to report either.
|
||||
Assert.IsNull(r.AssemblyName, "dry_run must not load (or name) an assembly");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_DryRun_CompileError_ReturnsSameDiagnostics()
|
||||
{
|
||||
var file = WriteTempScript(@"
|
||||
public static class Builder
|
||||
{
|
||||
public static int Build() { return 2 +; }
|
||||
}");
|
||||
var r = await RunScriptCommand.RunScript(file, dryRun: true);
|
||||
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.AreEqual("Compilation Failed", r.Error);
|
||||
Assert.Greater(r.Diagnostics.Count, 0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Entry point resolution errors
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_UnknownEntry_ErrorsClearly()
|
||||
{
|
||||
var file = WriteTempScript(@"
|
||||
public static class Builder
|
||||
{
|
||||
public static int Build() { return 1; }
|
||||
}");
|
||||
var r = await RunScriptCommand.RunScript(file, entry: "Nope.DoesNotExist");
|
||||
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.AreEqual("Entry Point Not Found", r.Error);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_AmbiguousEntry_ErrorsClearly()
|
||||
{
|
||||
var file = WriteTempScript(@"
|
||||
public static class Builder
|
||||
{
|
||||
public static int A() { return 1; }
|
||||
public static int B() { return 2; }
|
||||
}");
|
||||
var r = await RunScriptCommand.RunScript(file);
|
||||
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.AreEqual("Entry Point Not Found", r.Error);
|
||||
StringAssert.Contains("Ambiguous", r.ErrorDetails);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_NoEntry_FallsBackToMain()
|
||||
{
|
||||
var file = WriteTempScript(@"
|
||||
public static class Builder
|
||||
{
|
||||
public static int Helper() { return 1; }
|
||||
public static int Main() { return 99; }
|
||||
}");
|
||||
var r = await RunScriptCommand.RunScript(file);
|
||||
|
||||
Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails);
|
||||
Assert.AreEqual(99, r.Result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_InstanceMethodEntry_Rejected()
|
||||
{
|
||||
var file = WriteTempScript(@"
|
||||
public class Builder
|
||||
{
|
||||
public int Build() { return 1; } // instance, not static
|
||||
public static int Trigger() { return 0; }
|
||||
}");
|
||||
var r = await RunScriptCommand.RunScript(file, entry: "Builder.Build");
|
||||
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.AreEqual("Entry Point Not Found", r.Error);
|
||||
StringAssert.Contains("instance method", r.ErrorDetails);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_GenericEntry_RejectedClearly()
|
||||
{
|
||||
// A generic method can't be invoked without type arguments — instead of a raw
|
||||
// reflection exception, the resolver must say so.
|
||||
var file = WriteTempScript(@"
|
||||
public static class Builder
|
||||
{
|
||||
public static T Build<T>() { return default(T); }
|
||||
public static int Other() { return 1; }
|
||||
}");
|
||||
var r = await RunScriptCommand.RunScript(file, entry: "Builder.Build");
|
||||
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.AreEqual("Entry Point Not Found", r.Error);
|
||||
StringAssert.Contains("generic entry points are not supported", r.ErrorDetails);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_NestedTypeEntry_Resolves()
|
||||
{
|
||||
// Callers naturally write nested types with dots ("Outer.Inner.Method"); reflection
|
||||
// names them "Outer+Inner". The resolver retries the '+' form.
|
||||
var file = WriteTempScript(@"
|
||||
public static class Outer
|
||||
{
|
||||
public static class Inner
|
||||
{
|
||||
public static int Build() { return 7; }
|
||||
}
|
||||
}");
|
||||
var r = await RunScriptCommand.RunScript(file, entry: "Outer.Inner.Build");
|
||||
|
||||
Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails);
|
||||
Assert.AreEqual(7, r.Result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Argument coercion
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_Args_PrimitivesEnumAndStringArray_Coerced()
|
||||
{
|
||||
var file = WriteTempScript(@"
|
||||
public static class Builder
|
||||
{
|
||||
public enum Kind { Red, Green, Blue }
|
||||
public static string Build(int i, float f, Kind k, string[] names)
|
||||
{
|
||||
return i + "":"" + f + "":"" + k + "":"" + names.Length;
|
||||
}
|
||||
}");
|
||||
var args = new JArray(3, 2.5f, "Green", new JArray("x", "y"));
|
||||
var r = await RunScriptCommand.RunScript(file, entry: "Builder.Build", args: args);
|
||||
|
||||
Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails);
|
||||
Assert.AreEqual("3:2.5:Green:2", r.Result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_Args_ObjectRefParameter_ResolvesUnityObject()
|
||||
{
|
||||
var go = TrackGo(new GameObject("RunScript_RefTarget"));
|
||||
|
||||
var file = WriteTempScript(@"
|
||||
using UnityEngine;
|
||||
public static class Builder
|
||||
{
|
||||
public static string Build(GameObject go) { return go.name; }
|
||||
}");
|
||||
var handle = JObject.FromObject(new { instanceId = PipelineUtils.GetObjectId(go) });
|
||||
var r = await RunScriptCommand.RunScript(file, entry: "Builder.Build", args: new JArray(handle));
|
||||
|
||||
Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails);
|
||||
Assert.AreEqual("RunScript_RefTarget", r.Result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_Args_MissingRequiredArgument_Errors()
|
||||
{
|
||||
var file = WriteTempScript(@"
|
||||
public static class Builder
|
||||
{
|
||||
public static int Build(int required) { return required; }
|
||||
}");
|
||||
var r = await RunScriptCommand.RunScript(file, entry: "Builder.Build", args: new JArray());
|
||||
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.AreEqual("Bad Request", r.Error);
|
||||
StringAssert.Contains("Missing required argument", r.ErrorDetails);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_Args_DefaultValueUsedWhenOmitted()
|
||||
{
|
||||
var file = WriteTempScript(@"
|
||||
public static class Builder
|
||||
{
|
||||
public static int Build(int a, int b = 7) { return a + b; }
|
||||
}");
|
||||
var r = await RunScriptCommand.RunScript(file, entry: "Builder.Build", args: new JArray(3));
|
||||
|
||||
Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails);
|
||||
Assert.AreEqual(10, r.Result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_Args_TooMany_Errors()
|
||||
{
|
||||
var file = WriteTempScript(@"
|
||||
public static class Builder
|
||||
{
|
||||
public static int Build(int a) { return a; }
|
||||
}");
|
||||
var r = await RunScriptCommand.RunScript(file, entry: "Builder.Build", args: new JArray(1, 2, 3));
|
||||
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.AreEqual("Bad Request", r.Error);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_Args_NullForValueType_ErrorsInsteadOfSilentDefault()
|
||||
{
|
||||
// JSON null used to silently become default(T) for value-type parameters — a caller
|
||||
// mistake that must surface as a coercion error instead.
|
||||
var file = WriteTempScript(@"
|
||||
public static class Builder
|
||||
{
|
||||
public static int Build(int a) { return a; }
|
||||
}");
|
||||
var r = await RunScriptCommand.RunScript(file, entry: "Builder.Build", args: new JArray((object)null));
|
||||
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.AreEqual("Bad Request", r.Error);
|
||||
StringAssert.Contains("null", r.ErrorDetails);
|
||||
StringAssert.Contains("value type", r.ErrorDetails);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_Args_NullForNullableValueType_StillWorks()
|
||||
{
|
||||
var file = WriteTempScript(@"
|
||||
public static class Builder
|
||||
{
|
||||
public static string Build(int? a) { return a.HasValue ? a.Value.ToString() : ""none""; }
|
||||
}");
|
||||
var r = await RunScriptCommand.RunScript(file, entry: "Builder.Build", args: new JArray((object)null));
|
||||
|
||||
Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails);
|
||||
Assert.AreEqual("none", r.Result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Runtime exceptions
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_RuntimeException_StructuredError()
|
||||
{
|
||||
var file = WriteTempScript(@"
|
||||
public static class Builder
|
||||
{
|
||||
public static int Build() { throw new System.InvalidOperationException(""boom""); }
|
||||
}");
|
||||
var r = await RunScriptCommand.RunScript(file);
|
||||
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.AreEqual("Runtime Error", r.Error);
|
||||
StringAssert.Contains("boom", r.ErrorDetails);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_RuntimeException_ErrorDetails_MapToSourceFileAndLine()
|
||||
{
|
||||
// The PDB acceptance criterion: ephemeral runs always emit a source-mapped portable
|
||||
// PDB, so a runtime exception's stack trace must map back to the original .cs file
|
||||
// and line — not to an anonymous in-memory buffer.
|
||||
var file = WriteTempScript(
|
||||
"public static class Builder\n" +
|
||||
"{\n" +
|
||||
" public static int Build()\n" +
|
||||
" {\n" +
|
||||
" throw new System.InvalidOperationException(\"mapped\");\n" + // line 5
|
||||
" }\n" +
|
||||
"}\n");
|
||||
var r = await RunScriptCommand.RunScript(file);
|
||||
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.AreEqual("Runtime Error", r.Error);
|
||||
var fileName = Path.GetFileName(file);
|
||||
StringAssert.Contains(fileName, r.ErrorDetails, "stack trace should reference the source file");
|
||||
// .NET renders "file.cs:line 5", Mono "file.cs:5" — accept either, but demand the
|
||||
// throw site's actual line number.
|
||||
Assert.IsTrue(
|
||||
Regex.IsMatch(r.ErrorDetails, Regex.Escape(fileName) + @":(line )?5\b"),
|
||||
$"stack trace should carry the source line of the throw. ErrorDetails:\n{r.ErrorDetails}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Async entry points
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_AsyncTaskEntry_Awaited_ExceptionSurfaced()
|
||||
{
|
||||
// A Task-returning entry point must be awaited: without it the command reports
|
||||
// success the moment the first await yields, and the exception is never observed.
|
||||
var file = WriteTempScript(@"
|
||||
using System.Threading.Tasks;
|
||||
public static class Builder
|
||||
{
|
||||
public static async Task Build()
|
||||
{
|
||||
await Task.Delay(10).ConfigureAwait(false);
|
||||
throw new System.InvalidOperationException(""async boom"");
|
||||
}
|
||||
}");
|
||||
var r = await RunScriptCommand.RunScript(file);
|
||||
|
||||
Assert.IsFalse(r.Success, "the faulted task's exception must fail the run");
|
||||
Assert.AreEqual("Runtime Error", r.Error);
|
||||
StringAssert.Contains("async boom", r.ErrorDetails);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_AsyncTaskEntry_CompletesAndReturnsNull()
|
||||
{
|
||||
var file = WriteTempScript(@"
|
||||
using System.Threading.Tasks;
|
||||
public static class Builder
|
||||
{
|
||||
public static int Ran;
|
||||
public static async Task Build()
|
||||
{
|
||||
await Task.Delay(10).ConfigureAwait(false);
|
||||
Ran = 1;
|
||||
}
|
||||
}");
|
||||
var r = await RunScriptCommand.RunScript(file);
|
||||
|
||||
Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails);
|
||||
// async Task materializes as Task<VoidTaskResult>; the placeholder must not leak
|
||||
// into the result.
|
||||
Assert.IsNull(r.Result, "a plain async Task entry has no value to return");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_AsyncTaskIntEntry_ReturnsValue()
|
||||
{
|
||||
var file = WriteTempScript(@"
|
||||
using System.Threading.Tasks;
|
||||
public static class Builder
|
||||
{
|
||||
public static async Task<int> Build()
|
||||
{
|
||||
await Task.Delay(10).ConfigureAwait(false);
|
||||
return 42;
|
||||
}
|
||||
}");
|
||||
var r = await RunScriptCommand.RunScript(file);
|
||||
|
||||
Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails);
|
||||
Assert.AreEqual(42, r.Result, "Task<int>.Result should be unwrapped into the response");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_AsyncEntry_ResumingOnUnityContext_CompletesWithoutDeadlock()
|
||||
{
|
||||
// THE deadlock regression (found by manual testing): awaits without
|
||||
// ConfigureAwait(false) resume on Unity's main-thread SynchronizationContext — the
|
||||
// default way anyone writes async in Unity. The old blocking GetResult() wait
|
||||
// deadlocked the whole Editor permanently on this input; the await-based wait must
|
||||
// let the editor loop pump those continuations and complete normally.
|
||||
var file = WriteTempScript(@"
|
||||
using System.Threading.Tasks;
|
||||
public static class Builder
|
||||
{
|
||||
public static async Task<int> Build(int a, int b)
|
||||
{
|
||||
await Task.Yield();
|
||||
return a * b;
|
||||
}
|
||||
}");
|
||||
var r = await RunScriptCommand.RunScript(file, args: new JArray(6, 7));
|
||||
|
||||
Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails);
|
||||
Assert.AreEqual(42, r.Result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_AsyncEntry_ExceedingBudget_TimesOutStructured()
|
||||
{
|
||||
// An entry task that outlives timeout_ms must produce a structured Timeout error
|
||||
// (the task keeps running detached), never hang the command or the Editor.
|
||||
var file = WriteTempScript(@"
|
||||
using System.Threading.Tasks;
|
||||
public static class Builder
|
||||
{
|
||||
public static async Task<int> Build()
|
||||
{
|
||||
await Task.Delay(30000);
|
||||
return 1;
|
||||
}
|
||||
}");
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var r = await RunScriptCommand.RunScript(file, timeoutMs: 1500);
|
||||
sw.Stop();
|
||||
|
||||
Assert.IsFalse(r.Success, "the entry task cannot have completed within the budget");
|
||||
Assert.AreEqual("Timeout", r.Error);
|
||||
StringAssert.Contains("keeps running detached", r.ErrorDetails);
|
||||
Assert.Less(sw.ElapsedMilliseconds, 15000,
|
||||
"the command must give up at ~timeout_ms, not wait out the 30s entry task");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Bad input
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_NullFile_BadRequest()
|
||||
{
|
||||
var r = await RunScriptCommand.RunScript(null);
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.AreEqual("Bad Request", r.Error);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_NonCsFile_BadRequest()
|
||||
{
|
||||
var r = await RunScriptCommand.RunScript("Something.txt");
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.AreEqual("Bad Request", r.Error);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_MissingFile_FileNotFound()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "run_script_missing_" + System.Guid.NewGuid().ToString("N") + ".cs");
|
||||
var r = await RunScriptCommand.RunScript(path);
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.AreEqual("File Not Found", r.Error);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_UnknownMode_BadRequest()
|
||||
{
|
||||
var file = WriteTempScript(@"
|
||||
public static class Builder { public static int Build() { return 1; } }");
|
||||
var r = await RunScriptCommand.RunScript(file, mode: "banana");
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.AreEqual("Bad Request", r.Error);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region defines
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_Defines_ExtendActiveEditorDefines()
|
||||
{
|
||||
// Ephemeral compiles are seeded with the project's active editor defines
|
||||
// (EditorUserBuildSettings.activeScriptCompilationDefines), so UNITY_EDITOR is set by
|
||||
// default — sources behave like project code. The user's `defines` are APPENDED to
|
||||
// that set, not a replacement for it.
|
||||
var source = @"
|
||||
public static class Builder
|
||||
{
|
||||
public static string Build()
|
||||
{
|
||||
var parts = new System.Collections.Generic.List<string>();
|
||||
#if UNITY_EDITOR
|
||||
parts.Add(""editor"");
|
||||
#endif
|
||||
#if MY_FLAG
|
||||
parts.Add(""flag"");
|
||||
#endif
|
||||
return string.Join("","", parts);
|
||||
}
|
||||
}";
|
||||
var file = WriteTempScript(source);
|
||||
|
||||
var withoutFlag = await RunScriptCommand.RunScript(file);
|
||||
Assert.IsTrue(withoutFlag.Success, withoutFlag.Error + " / " + withoutFlag.ErrorDetails);
|
||||
Assert.AreEqual("editor", withoutFlag.Result, "UNITY_EDITOR should be defined by default");
|
||||
|
||||
var withFlag = await RunScriptCommand.RunScript(file, defines: new[] { "MY_FLAG" });
|
||||
Assert.IsTrue(withFlag.Success, withFlag.Error + " / " + withFlag.ErrorDetails);
|
||||
Assert.AreEqual("editor,flag", withFlag.Result, "a custom define must extend, not replace, the active defines");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Path resolution
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_RelativePath_ResolvesAgainstProjectRoot()
|
||||
{
|
||||
// Relative paths must resolve against the PROJECT ROOT (parent of Assets/), not the
|
||||
// process working directory. Temporarily move the CWD elsewhere to prove it.
|
||||
var projectRoot = Directory.GetParent(Application.dataPath).FullName;
|
||||
var fileName = "run_script_rel_" + System.Guid.NewGuid().ToString("N") + ".cs";
|
||||
var relative = Path.Combine("Temp", fileName);
|
||||
var absolute = Path.Combine(projectRoot, relative);
|
||||
Directory.CreateDirectory(Path.Combine(projectRoot, "Temp"));
|
||||
File.WriteAllText(absolute, @"
|
||||
public static class Builder
|
||||
{
|
||||
public static int Build() { return 11; }
|
||||
}");
|
||||
m_TempFiles.Add(absolute);
|
||||
|
||||
var originalCwd = Directory.GetCurrentDirectory();
|
||||
try
|
||||
{
|
||||
Directory.SetCurrentDirectory(Path.GetTempPath());
|
||||
var r = await RunScriptCommand.RunScript(relative);
|
||||
|
||||
Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails);
|
||||
Assert.AreEqual(11, r.Result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.SetCurrentDirectory(originalCwd);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region hotpatch delegation
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_Hotpatch_MissingFile_MapsReloadFileFailure()
|
||||
{
|
||||
// hotpatch delegates to reload_file; a clearly-missing file must surface as a mapped
|
||||
// failure rather than throwing, proving the delegation + error mapping.
|
||||
var path = Path.Combine(Path.GetTempPath(), "run_script_hotpatch_missing_" + System.Guid.NewGuid().ToString("N") + ".cs");
|
||||
var r = await RunScriptCommand.RunScript(path, mode: "hotpatch");
|
||||
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.IsNotNull(r.Error);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_Hotpatch_DryRun_Rejected_NothingApplied()
|
||||
{
|
||||
// dry_run has no compile-only meaning in hotpatch mode — reload_file would apply live
|
||||
// method replacements. It must be rejected up front, not silently applied.
|
||||
// Proof nothing ran: the file deliberately does not exist. If the guard didn't fire,
|
||||
// reload_file would answer "File Not Found" — a "Bad Request" naming dry_run proves the
|
||||
// request was rejected before any hotpatch machinery (or compile) was touched.
|
||||
var path = Path.Combine(Path.GetTempPath(), "run_script_hotpatch_dryrun_" + System.Guid.NewGuid().ToString("N") + ".cs");
|
||||
var r = await RunScriptCommand.RunScript(path, mode: "hotpatch", dryRun: true);
|
||||
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.AreEqual("Bad Request", r.Error);
|
||||
StringAssert.Contains("dry_run", r.ErrorDetails);
|
||||
StringAssert.Contains("hotpatch", r.ErrorDetails);
|
||||
Assert.AreEqual(0, r.CompileMs, "nothing should have compiled");
|
||||
Assert.AreEqual(0, r.ExecuteMs, "nothing should have been applied");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_Hotpatch_EntrySupplied_Rejected()
|
||||
{
|
||||
// hotpatch never invokes an entry point, so a supplied entry must be rejected by name
|
||||
// instead of silently ignored.
|
||||
var path = Path.Combine(Path.GetTempPath(), "run_script_hotpatch_entry_" + System.Guid.NewGuid().ToString("N") + ".cs");
|
||||
var r = await RunScriptCommand.RunScript(path, entry: "Builder.Build", mode: "hotpatch");
|
||||
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.AreEqual("Bad Request", r.Error);
|
||||
StringAssert.Contains("'entry'", r.ErrorDetails);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RunScript_Hotpatch_ArgsSupplied_Rejected()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "run_script_hotpatch_args_" + System.Guid.NewGuid().ToString("N") + ".cs");
|
||||
var r = await RunScriptCommand.RunScript(path, args: new JArray(1), mode: "hotpatch");
|
||||
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.AreEqual("Bad Request", r.Error);
|
||||
StringAssert.Contains("'args'", r.ErrorDetails);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ViaClient
|
||||
|
||||
[Test]
|
||||
public void RunScript_ViaClient_ReturnsResult()
|
||||
{
|
||||
var file = WriteTempScript(@"
|
||||
public static class Builder
|
||||
{
|
||||
public static int Build() { return 6 * 7; }
|
||||
}");
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("run_script", new { file, timeout_ms = 15000 });
|
||||
Assert.IsTrue(response.IsSuccess, response.Error);
|
||||
|
||||
var r = response.GetTypedResponse<RunScriptResponse>();
|
||||
Assert.IsNotNull(r, "Should deserialize a RunScriptResponse");
|
||||
Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails);
|
||||
Assert.AreEqual(42, r.Result);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RunScript_ViaClient_ArgsCoercedFromJsonArray()
|
||||
{
|
||||
var file = WriteTempScript(@"
|
||||
public static class Builder
|
||||
{
|
||||
public static string Build(int i, string[] names) { return i + "":"" + names.Length; }
|
||||
}");
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("run_script", new
|
||||
{
|
||||
file,
|
||||
entry = "Builder.Build",
|
||||
args = new object[] { 5, new[] { "a", "b", "c" } }
|
||||
});
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, response.Error);
|
||||
var r = response.GetTypedResponse<RunScriptResponse>();
|
||||
Assert.IsNotNull(r);
|
||||
Assert.IsTrue(r.Success, r.Error + " / " + r.ErrorDetails);
|
||||
Assert.AreEqual("5:3", r.Result);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RunScript_ViaClient_TimeoutMs_GovernsDispatcherWaitBudget()
|
||||
{
|
||||
// Proves timeout_ms actually reaches the dispatcher's wait budget end-to-end
|
||||
// (UUM-148641 semantics extended to run_script): an entry that sleeps 2000ms with
|
||||
// timeout_ms=300 must time out at ~300ms instead of waiting out the sleep (or the
|
||||
// dispatcher's 60s default).
|
||||
var file = WriteTempScript(@"
|
||||
public static class Builder
|
||||
{
|
||||
public static int Build() { System.Threading.Thread.Sleep(2000); return 1; }
|
||||
}");
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
LogAssert.Expect(LogType.Error, new Regex("Failed to handle /api/exec request.*timed out after 300ms"));
|
||||
|
||||
// Like the matching eval test: PipelineTestServer.Execute's pump loop runs the
|
||||
// dequeued work item synchronously, so it can't observe completion until the
|
||||
// compile + 2000ms sleep finish — give the outer wait headroom even though the
|
||||
// dispatcher timeout itself fires at ~300ms.
|
||||
var response = server.Execute("run_script", new { file, timeout_ms = 300 }, timeoutMs: 30000);
|
||||
|
||||
Assert.IsFalse(response.IsSuccess,
|
||||
$"Expected the 300ms budget to time out instead of waiting for the 2000ms sleep: {response.RawResponse}");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RunScript_ViaClient_CompileError_ReturnsDiagnostics()
|
||||
{
|
||||
var file = WriteTempScript(@"
|
||||
public static class Builder
|
||||
{
|
||||
public static int Build() { return 2 +; }
|
||||
}");
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("run_script", new { file });
|
||||
|
||||
var r = response.GetTypedResponse<RunScriptResponse>();
|
||||
Assert.IsNotNull(r);
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.AreEqual("Compilation Failed", r.Error);
|
||||
Assert.Greater(r.Diagnostics.Count, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e0b145d8729944dca39ad7d64c9a1876
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,681 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Editor.Commands.Scripts;
|
||||
using Unity.Pipeline.Models;
|
||||
using Unity.Pipeline.Tests.Runtime; // AttachByPathFixture lives in the runtime test assembly (addable)
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
using Object = UnityEngine.Object;
|
||||
using Unity.Pipeline;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor.Scripts
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the CLI-195 script-management / reference-linking commands, exercised both directly
|
||||
/// (calling the static command method) and ViaClient (over HTTP through <see cref="PipelineTestServer"/>).
|
||||
///
|
||||
/// create_script's own domain reload IS exercised in-test — see
|
||||
/// <see cref="CreateScript_WritesFileAndReturnsAssetIdentity"/>, which forces and survives it
|
||||
/// (UUM-148802). What still can't complete inside a single test is attach_script against the type
|
||||
/// that reload just compiled: no local state survives a real domain reload, so there is nothing
|
||||
/// left in-process afterward to hand to attach_script. We therefore cover:
|
||||
/// * set_serialized_field -> get_serialized_fields round-trips (primitive, enum, vector),
|
||||
/// * wiring a [SerializeField] object reference and reading it back as a handle,
|
||||
/// * the recoverable "attach before compile" error path (attaching a type name that isn't
|
||||
/// compiled), which is exactly what an agent hits if it skips the recompile step.
|
||||
/// The happy-path attach is validated against an ALREADY-COMPILED test component
|
||||
/// (<see cref="ScriptCommandTestBehaviour"/>) so the type exists without a reload. Attaching a
|
||||
/// type from a script create_script just wrote must still be verified in a live Editor (see PR notes).
|
||||
/// </summary>
|
||||
class ScriptCommandsTests
|
||||
{
|
||||
// Shared with CreateScript_WritesFileAndReturnsAssetIdentity, whose own try/finally spans a
|
||||
// real domain reload (WaitForDomainReload) and isn't reliably run when the test fails or is
|
||||
// resumed across that reload — a leftover folder then fails the *next* run too (the script
|
||||
// file it writes already exists). TearDown always runs, reload or not, so clean up there.
|
||||
private const string k_CreateScriptTestFolder = "Assets/__CLI195Test";
|
||||
|
||||
// SessionState survives the domain reload below, unlike a field, so TearDown can tell "this test
|
||||
// created the folder" apart from "it already existed" and only ever delete the one it owns.
|
||||
private const string k_CreateScriptTestFolderOwnedKey = "Pipeline.Tests.CLI195TestFolderOwned";
|
||||
|
||||
private GameObject m_Go;
|
||||
private GameObject m_RefTarget;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
m_Go = new GameObject("CLI195_Subject");
|
||||
m_RefTarget = new GameObject("CLI195_RefTarget");
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
if (m_Go != null) Object.DestroyImmediate(m_Go);
|
||||
if (m_RefTarget != null) Object.DestroyImmediate(m_RefTarget);
|
||||
m_Go = null;
|
||||
m_RefTarget = null;
|
||||
ProjectPaths.ResetAuthoringRoot();
|
||||
|
||||
if (SessionState.GetBool(k_CreateScriptTestFolderOwnedKey, false))
|
||||
{
|
||||
SessionState.EraseBool(k_CreateScriptTestFolderOwnedKey);
|
||||
if (AssetDatabase.IsValidFolder(k_CreateScriptTestFolder))
|
||||
{
|
||||
AssetDatabase.DeleteAsset(k_CreateScriptTestFolder);
|
||||
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ObjectRef ById(Object o) => new ObjectRef { InstanceId = PipelineUtils.GetObjectId(o) };
|
||||
|
||||
#region Direct — set/get round-trips
|
||||
|
||||
[Test]
|
||||
public void SetThenGet_Primitive_RoundTrips()
|
||||
{
|
||||
var comp = m_Go.AddComponent<ScriptCommandTestBehaviour>();
|
||||
|
||||
SerializedFieldCommands.SetSerializedField(ById(comp), "m_Speed",
|
||||
Newtonsoft.Json.Linq.JToken.FromObject(42));
|
||||
|
||||
Assert.AreEqual(42, comp.Speed, "Direct field value should reflect the set");
|
||||
|
||||
// Read it back through the command and confirm the reported value matches.
|
||||
var read = SerializedFieldCommands.GetSerializedFields(ById(comp), "m_Speed");
|
||||
var json = Newtonsoft.Json.Linq.JObject.FromObject(read);
|
||||
Assert.AreEqual(42, (int)json["fields"][0]["value"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetThenGet_Enum_RoundTripsByName()
|
||||
{
|
||||
var comp = m_Go.AddComponent<ScriptCommandTestBehaviour>();
|
||||
|
||||
SerializedFieldCommands.SetSerializedField(ById(comp), "m_Mode",
|
||||
Newtonsoft.Json.Linq.JToken.FromObject("Aggressive"));
|
||||
|
||||
Assert.AreEqual(ScriptCommandTestBehaviour.EnemyMode.Aggressive, comp.Mode);
|
||||
|
||||
var read = SerializedFieldCommands.GetSerializedFields(ById(comp), "m_Mode");
|
||||
var json = Newtonsoft.Json.Linq.JObject.FromObject(read);
|
||||
Assert.AreEqual("Aggressive", (string)json["fields"][0]["value"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetThenGet_Vector3_RoundTrips()
|
||||
{
|
||||
var comp = m_Go.AddComponent<ScriptCommandTestBehaviour>();
|
||||
|
||||
SerializedFieldCommands.SetSerializedField(ById(comp), "m_Offset",
|
||||
Newtonsoft.Json.Linq.JToken.FromObject(new { x = 1f, y = 2f, z = 3f }));
|
||||
|
||||
Assert.AreEqual(new Vector3(1, 2, 3), comp.Offset);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Direct — object-reference wiring
|
||||
|
||||
[Test]
|
||||
public void SetObjectReference_WiresAndReadsBackAsHandle()
|
||||
{
|
||||
var comp = m_Go.AddComponent<ScriptCommandTestBehaviour>();
|
||||
|
||||
// Wire the [SerializeField] GameObject reference to another scene object by instanceId.
|
||||
SerializedFieldCommands.SetSerializedField(ById(comp), "m_Target",
|
||||
Newtonsoft.Json.Linq.JToken.FromObject(new { instanceId = PipelineUtils.GetObjectId(m_RefTarget) }));
|
||||
|
||||
Assert.AreSame(m_RefTarget, comp.Target, "The reference should point at the wired object");
|
||||
|
||||
// Reading it back should describe the referenced object as a re-usable handle.
|
||||
var read = SerializedFieldCommands.GetSerializedFields(ById(comp), "m_Target");
|
||||
var json = Newtonsoft.Json.Linq.JObject.FromObject(read);
|
||||
var value = json["fields"][0]["value"];
|
||||
Assert.AreEqual(PipelineUtils.GetObjectId(m_RefTarget), value["instanceId"].ToObject<ObjectId>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetArrayElement_ResizesAndWiresObjectReference_RoundTrips()
|
||||
{
|
||||
var comp = m_Go.AddComponent<ScriptCommandTestBehaviour>();
|
||||
|
||||
// Grow the array to one element via the native 'Array.size' path...
|
||||
SerializedFieldCommands.SetSerializedField(ById(comp), "m_Waypoints.Array.size",
|
||||
Newtonsoft.Json.Linq.JToken.FromObject(1));
|
||||
Assert.AreEqual(1, comp.Waypoints.Length, "Array.size should resize the backing array");
|
||||
|
||||
// ...then wire element [0] to a scene object via the 'Array.data[i]' path.
|
||||
SerializedFieldCommands.SetSerializedField(ById(comp), "m_Waypoints.Array.data[0]",
|
||||
Newtonsoft.Json.Linq.JToken.FromObject(new { instanceId = PipelineUtils.GetObjectId(m_RefTarget) }));
|
||||
Assert.AreSame(m_RefTarget, comp.Waypoints[0], "Array element should point at the wired object");
|
||||
|
||||
// The whole array reads back as an object reporting its length...
|
||||
var arr = SerializedFieldCommands.GetSerializedFields(ById(comp), "m_Waypoints");
|
||||
var arrJson = Newtonsoft.Json.Linq.JObject.FromObject(arr);
|
||||
Assert.AreEqual(1, (int)arrJson["fields"][0]["arrayLength"]);
|
||||
|
||||
// ...and the element reads back as a re-usable handle to the referenced object.
|
||||
var elem = SerializedFieldCommands.GetSerializedFields(ById(comp), "m_Waypoints.Array.data[0]");
|
||||
var elemJson = Newtonsoft.Json.Linq.JObject.FromObject(elem);
|
||||
Assert.AreEqual(PipelineUtils.GetObjectId(m_RefTarget), elemJson["fields"][0]["value"]["instanceId"].ToObject<ObjectId>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Direct — attach
|
||||
|
||||
[Test]
|
||||
public void AttachScript_CompiledType_AddsComponent()
|
||||
{
|
||||
var result = AttachScriptCommand.AttachScript(ById(m_Go), nameof(ScriptCommandTestBehaviour));
|
||||
|
||||
Assert.IsNotNull(m_Go.GetComponent<ScriptCommandTestBehaviour>(), "Component should be attached");
|
||||
Assert.AreEqual(nameof(ScriptCommandTestBehaviour), result.Type);
|
||||
}
|
||||
|
||||
// CLI-224: explicitly pass type via the named arg form (script left null).
|
||||
[Test]
|
||||
public void AttachScript_ByType_NamedArg_AddsComponent()
|
||||
{
|
||||
var result = AttachScriptCommand.AttachScript(
|
||||
ById(m_Go), type: nameof(ScriptCommandTestBehaviour), script: null);
|
||||
|
||||
Assert.IsNotNull(m_Go.GetComponent<ScriptCommandTestBehaviour>(), "Component should be attached by type");
|
||||
Assert.AreEqual(nameof(ScriptCommandTestBehaviour), result.Type);
|
||||
}
|
||||
|
||||
// CLI-224: attach by ASSET PATH. The backing class is resolved from the .cs asset via
|
||||
// MonoScript.GetClass() (the agent passes a path, not a class name). Unity requires a
|
||||
// MonoBehaviour's file name to match its class name to add it, so the fixture file/class share
|
||||
// a name; the feature under test is path-based resolution rather than the name itself.
|
||||
[Test]
|
||||
public void AttachScript_ByScriptPath_ResolvesClassViaGetClass_AddsComponent()
|
||||
{
|
||||
var scriptPath = FixtureScriptPath();
|
||||
|
||||
var result = AttachScriptCommand.AttachScript(ById(m_Go), type: null, script: scriptPath);
|
||||
|
||||
Assert.IsNotNull(m_Go.GetComponent<AttachByPathFixture>(),
|
||||
"The fixture component should be attached by resolving its script asset path");
|
||||
Assert.AreEqual(nameof(AttachByPathFixture), result.Type,
|
||||
"Resolved type should come from MonoScript.GetClass() on the supplied path");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AttachScript_BothTypeAndScript_Throws()
|
||||
{
|
||||
var scriptPath = FixtureScriptPath();
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
AttachScriptCommand.AttachScript(
|
||||
ById(m_Go), type: nameof(ScriptCommandTestBehaviour), script: scriptPath));
|
||||
StringAssert.Contains("not both", ex.Message);
|
||||
Assert.IsNull(m_Go.GetComponent<ScriptCommandTestBehaviour>(), "Nothing should attach on a bad-arg call");
|
||||
Assert.IsNull(m_Go.GetComponent<AttachByPathFixture>(), "Nothing should attach on a bad-arg call");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AttachScript_NeitherTypeNorScript_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
AttachScriptCommand.AttachScript(ById(m_Go), type: null, script: null));
|
||||
StringAssert.Contains("Provide either", ex.Message);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AttachScript_ByScriptPath_NoMonoScriptAtPath_ThrowsArgumentException()
|
||||
{
|
||||
// A path that resolves to no MonoScript asset is a caller input mistake (parameter
|
||||
// validation), not a recompile-recoverable failure — so it surfaces as ArgumentException.
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
AttachScriptCommand.AttachScript(
|
||||
ById(m_Go), type: null, script: "Assets/__NoSuch__/Missing.cs"));
|
||||
StringAssert.Contains("No MonoScript", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locate the on-disk script asset backing <see cref="AttachByPathFixture"/> in a
|
||||
/// path-independent way (the package may be imported from anywhere): search the AssetDatabase
|
||||
/// for the MonoScript by file name and confirm it via <see cref="MonoScript.GetClass"/>.
|
||||
/// </summary>
|
||||
private static string FixtureScriptPath()
|
||||
{
|
||||
// Locate the .cs asset backing AttachByPathFixture by file name, confirmed via
|
||||
// MonoScript.GetClass(). FindAssets indexes package assets, so this is robust regardless of
|
||||
// where the package is imported — and avoids MonoScript.FromMonoBehaviour, whose
|
||||
// AssetDatabase path can come back empty for a type in a package assembly.
|
||||
foreach (var guid in AssetDatabase.FindAssets("AttachByPathFixture t:MonoScript"))
|
||||
{
|
||||
var p = AssetDatabase.GUIDToAssetPath(guid);
|
||||
if (!p.EndsWith("/AttachByPathFixture.cs"))
|
||||
continue;
|
||||
|
||||
var mono = AssetDatabase.LoadAssetAtPath<MonoScript>(p);
|
||||
if (mono != null && mono.GetClass() == typeof(AttachByPathFixture))
|
||||
return p;
|
||||
}
|
||||
|
||||
Assert.Ignore("AttachByPathFixture.cs MonoScript not found in the AssetDatabase; cannot exercise attach-by-path.");
|
||||
return null;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AttachScript_UncompiledType_ReturnsRecoverableError()
|
||||
{
|
||||
// Simulate the create-before-compile case: a type name that no loaded assembly knows.
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
AttachScriptCommand.AttachScript(ById(m_Go), "ThisTypeWasJustCreatedAndNotCompiledYet"));
|
||||
|
||||
// The message must be recoverable: it should point the agent at the recompile flow.
|
||||
StringAssert.Contains("recompile", ex.Message.ToLowerInvariant());
|
||||
Assert.IsNull(m_Go.GetComponent("ThisTypeWasJustCreatedAndNotCompiledYet"),
|
||||
"Nothing should be attached when the type is unknown");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AttachScript_NonMonoBehaviourType_Throws()
|
||||
{
|
||||
// A component target is fine (we read its gameObject), but a non-MonoBehaviour type is not.
|
||||
var comp = m_Go.AddComponent<ScriptCommandTestBehaviour>();
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
AttachScriptCommand.AttachScript(ById(comp), nameof(System.String)));
|
||||
StringAssert.Contains("MonoBehaviour", ex.Message);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Direct — create_script (file write, survives its own domain reload)
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator CreateScript_WritesFileAndReturnsAssetIdentity()
|
||||
{
|
||||
const string expectedAssetPath = k_CreateScriptTestFolder + "/CLI195Generated.cs";
|
||||
|
||||
if (!AssetDatabase.IsValidFolder(k_CreateScriptTestFolder))
|
||||
{
|
||||
AssetDatabase.CreateFolder("Assets", "__CLI195Test");
|
||||
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||
SessionState.SetBool(k_CreateScriptTestFolderOwnedKey, true);
|
||||
}
|
||||
|
||||
var result = CreateScriptCommand.CreateScript("CLI195Generated", k_CreateScriptTestFolder, "Game.Generated");
|
||||
Assert.AreEqual(expectedAssetPath, result.AssetPath);
|
||||
|
||||
// create_script deliberately doesn't recompile (callers batch writes before paying
|
||||
// that cost once, see CreateScriptCommand's doc comment) — so importing the real .cs
|
||||
// file above just left Unity owing a compile pass. Left alone, that debt gets paid
|
||||
// off whenever Unity next decides it's safe to check — empirically, the next Play
|
||||
// Mode transition — landing mid-coroutine on an unrelated [UnityTest] elsewhere in
|
||||
// the suite and hanging it to its timeout (UUM-148802). Force and wait out the reload
|
||||
// right here instead, before anything else in the suite can be caught by it.
|
||||
EditorUtility.RequestScriptReload();
|
||||
yield return new WaitForDomainReload();
|
||||
|
||||
// Local state doesn't survive a real domain reload — the Test Runner resumes this
|
||||
// coroutine at the right point, but every captured local (even a plain string, not
|
||||
// just `result` itself) resets to its default. Re-derive what we check from the
|
||||
// (const) inputs rather than anything captured before the yield.
|
||||
Assert.IsTrue(System.IO.File.Exists(
|
||||
System.IO.Path.Combine(ProjectPaths.ProjectRoot, expectedAssetPath)),
|
||||
"The .cs file should be written to disk");
|
||||
|
||||
// Cleanup lives in TearDown, not a local try/finally: a finally wrapping this yield
|
||||
// isn't reliably run when the test fails or is resumed across the real domain reload
|
||||
// above, and a leftover folder then fails the next run too (CreateScript refuses to
|
||||
// overwrite an existing file).
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ViaClient
|
||||
|
||||
[Test]
|
||||
public void GetSerializedFields_ViaClient_NullValue_ReturnsExplicitNullResult()
|
||||
{
|
||||
// The full /api/exec path for a genuinely-null command result: a format="value" read
|
||||
// of an unassigned object-reference field is a SUCCESS whose value is null, and the
|
||||
// wire reply must carry an explicit "result":null — distinguishable from a reply with
|
||||
// no result at all (AUTHAPI-21 review).
|
||||
var comp = m_Go.AddComponent<ScriptCommandTestBehaviour>();
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("get_serialized_fields", new
|
||||
{
|
||||
target = new { instanceId = PipelineUtils.GetObjectId(comp) },
|
||||
field = "m_Target",
|
||||
format = "value"
|
||||
});
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, $"get should succeed: {response.Error} / {response.RawResponse}");
|
||||
Assert.IsTrue(response.HasValidJson);
|
||||
Assert.IsTrue(response.JsonResponse["success"].ToObject<bool>());
|
||||
StringAssert.Contains("\"result\":null", response.RawResponse,
|
||||
"A null field value must arrive as an explicit null result on the wire");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetThenGet_ViaClient_RoundTrips()
|
||||
{
|
||||
var comp = m_Go.AddComponent<ScriptCommandTestBehaviour>();
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var setResponse = server.Execute("set_serialized_field", new
|
||||
{
|
||||
target = new { instanceId = PipelineUtils.GetObjectId(comp) },
|
||||
field = "m_Speed",
|
||||
value = 7
|
||||
});
|
||||
Assert.IsTrue(setResponse.IsSuccess, $"set should succeed: {setResponse.Error} / {setResponse.RawResponse}");
|
||||
Assert.AreEqual(7, comp.Speed);
|
||||
|
||||
var getResponse = server.Execute("get_serialized_fields", new
|
||||
{
|
||||
target = new { instanceId = PipelineUtils.GetObjectId(comp) },
|
||||
field = "m_Speed"
|
||||
});
|
||||
Assert.IsTrue(getResponse.IsSuccess, $"get should succeed: {getResponse.Error}");
|
||||
Assert.IsTrue(getResponse.HasValidJson);
|
||||
var value = getResponse.JsonResponse["result"]["fields"][0]["value"];
|
||||
Assert.AreEqual(7, (int)value);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AttachScript_ViaClient_UncompiledType_ReturnsErrorResponse()
|
||||
{
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
// attach_script logs a Unity [Error] for the uncompiled type (the server surfaces the
|
||||
// command failure via Debug.LogError). Expect it so the unhandled-log check passes.
|
||||
UnityEngine.TestTools.LogAssert.Expect(LogType.Error,
|
||||
new System.Text.RegularExpressions.Regex("was not found in any loaded assembly"));
|
||||
|
||||
var response = server.Execute("attach_script", new
|
||||
{
|
||||
target = new { instanceId = PipelineUtils.GetObjectId(m_Go) },
|
||||
type = "DefinitelyNotCompiledYetComponent"
|
||||
});
|
||||
|
||||
// The recoverable error is surfaced as a command failure (HTTP 400), not a crash.
|
||||
Assert.IsFalse(response.IsSuccess, "Attaching an uncompiled type should fail at the command level");
|
||||
StringAssert.Contains("recompile", response.RawResponse.ToLowerInvariant(),
|
||||
"The error should tell the agent to recompile and retry");
|
||||
}
|
||||
}
|
||||
|
||||
// CLI-224: attach by --script (asset path) over HTTP.
|
||||
[Test]
|
||||
public void AttachScript_ViaClient_ByScriptPath_AddsComponent()
|
||||
{
|
||||
var scriptPath = FixtureScriptPath();
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var response = server.Execute("attach_script", new
|
||||
{
|
||||
target = new { instanceId = PipelineUtils.GetObjectId(m_Go) },
|
||||
script = scriptPath
|
||||
});
|
||||
|
||||
Assert.IsTrue(response.IsSuccess, $"attach by script path should succeed: {response.Error} / {response.RawResponse}");
|
||||
Assert.IsNotNull(m_Go.GetComponent<AttachByPathFixture>(),
|
||||
"Component should be attached via the script asset path over HTTP");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CLI-225 — get/set serialized fields by GameObject + component
|
||||
|
||||
[Test]
|
||||
public void GetSerializedFields_ByGameObjectAndComponent_ReturnsFields()
|
||||
{
|
||||
var rb = m_Go.AddComponent<Rigidbody>();
|
||||
|
||||
// Address the component via the GameObject handle + a component type name.
|
||||
var read = SerializedFieldCommands.GetSerializedFields(ById(m_Go), field: "m_Mass", component: "Rigidbody");
|
||||
var json = Newtonsoft.Json.Linq.JObject.FromObject(read);
|
||||
|
||||
Assert.AreEqual("Rigidbody", (string)json["type"], "Resolved object should be the Rigidbody, not the GameObject");
|
||||
Assert.AreEqual(rb.mass, (double)json["fields"][0]["value"], 0.001,
|
||||
"Reported value should be the component's live field value");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetSerializedField_ByGameObjectAndComponent_SetsAndReadsBack()
|
||||
{
|
||||
var rb = m_Go.AddComponent<Rigidbody>();
|
||||
|
||||
SerializedFieldCommands.SetSerializedField(ById(m_Go), "m_Mass",
|
||||
Newtonsoft.Json.Linq.JToken.FromObject(13.5), component: "Rigidbody");
|
||||
|
||||
Assert.AreEqual(13.5f, rb.mass, 0.001f, "Mass should be set on the GO's Rigidbody");
|
||||
|
||||
var read = SerializedFieldCommands.GetSerializedFields(ById(m_Go), field: "m_Mass", component: "Rigidbody");
|
||||
var json = Newtonsoft.Json.Linq.JObject.FromObject(read);
|
||||
Assert.AreEqual(13.5, (double)json["fields"][0]["value"], 0.001, "Read-back should reflect the set value");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSerializedFields_MultipleSameTypeComponents_ThrowsListingInstanceIds()
|
||||
{
|
||||
// Two Rigidbodies aren't allowed ([DisallowMultipleComponent]); use a test MonoBehaviour
|
||||
// that permits duplicates so the multi-match disambiguation path is reachable.
|
||||
var first = m_Go.AddComponent<ScriptCommandTestBehaviour>();
|
||||
var second = m_Go.AddComponent<ScriptCommandTestBehaviour>();
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
SerializedFieldCommands.GetSerializedFields(
|
||||
ById(m_Go), component: nameof(ScriptCommandTestBehaviour)));
|
||||
|
||||
// The error must list EACH instanceId so the agent can re-address unambiguously.
|
||||
StringAssert.Contains(PipelineUtils.GetObjectId(first).ToString(), ex.Message);
|
||||
StringAssert.Contains(PipelineUtils.GetObjectId(second).ToString(), ex.Message);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSerializedFields_AssetTargetWithComponent_Throws()
|
||||
{
|
||||
// --component is only meaningful for a GameObject target. Supplying it for an asset (here a
|
||||
// ScriptableObject, which has no components) is a misrouted request and must be rejected,
|
||||
// not silently ignored.
|
||||
var so = ScriptableObject.CreateInstance<ScriptCommandTestScriptable>();
|
||||
try
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
SerializedFieldCommands.GetSerializedFields(ById(so), component: "Rigidbody"));
|
||||
StringAssert.Contains("asset", ex.Message.ToLowerInvariant(),
|
||||
"The error should explain that an asset has no components");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(so);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSerializedFields_GameObjectWithoutComponent_ErrorMentionsComponentOption()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
SerializedFieldCommands.GetSerializedFields(ById(m_Go)));
|
||||
|
||||
StringAssert.Contains("--component", ex.Message,
|
||||
"The GameObject-without-component error should point at the new --component option");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSerializedFields_BareComponentInstanceId_StillWorks()
|
||||
{
|
||||
// The original addressing form (a component handle, no 'component' arg) must keep working.
|
||||
var comp = m_Go.AddComponent<ScriptCommandTestBehaviour>();
|
||||
|
||||
SerializedFieldCommands.SetSerializedField(ById(comp), "m_Speed",
|
||||
Newtonsoft.Json.Linq.JToken.FromObject(5));
|
||||
Assert.AreEqual(5, comp.Speed);
|
||||
|
||||
var read = SerializedFieldCommands.GetSerializedFields(ById(comp), "m_Speed");
|
||||
var json = Newtonsoft.Json.Linq.JObject.FromObject(read);
|
||||
Assert.AreEqual(5, (int)json["fields"][0]["value"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetSerializedField_ByGameObjectAndComponent_ViaClient_RoundTrips()
|
||||
{
|
||||
var rb = m_Go.AddComponent<Rigidbody>();
|
||||
using (var server = new PipelineTestServer())
|
||||
{
|
||||
var setResponse = server.Execute("set_serialized_field", new
|
||||
{
|
||||
target = new { instanceId = PipelineUtils.GetObjectId(m_Go) },
|
||||
component = "Rigidbody",
|
||||
field = "m_Mass",
|
||||
value = 8.25
|
||||
});
|
||||
Assert.IsTrue(setResponse.IsSuccess, $"set by GO+component should succeed: {setResponse.Error} / {setResponse.RawResponse}");
|
||||
Assert.AreEqual(8.25f, rb.mass, 0.001f);
|
||||
|
||||
var getResponse = server.Execute("get_serialized_fields", new
|
||||
{
|
||||
target = new { instanceId = PipelineUtils.GetObjectId(m_Go) },
|
||||
component = "Rigidbody",
|
||||
field = "m_Mass"
|
||||
});
|
||||
Assert.IsTrue(getResponse.IsSuccess, $"get by GO+component should succeed: {getResponse.Error}");
|
||||
var value = getResponse.JsonResponse["result"]["fields"][0]["value"];
|
||||
Assert.AreEqual(8.25, (double)value, 0.001);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetSerializedField_WholeArray_OfObjectRefs_RoundTrips()
|
||||
{
|
||||
// CLI-220 follow-up parity: set a whole array field from a single JSON array (rather than
|
||||
// element-by-element via "field.Array.data[i]").
|
||||
var comp = m_Go.AddComponent<ScriptCommandTestBehaviour>();
|
||||
var a = new GameObject("WP_A");
|
||||
var b = new GameObject("WP_B");
|
||||
try
|
||||
{
|
||||
SerializedFieldCommands.SetSerializedField(ById(comp), "m_Waypoints",
|
||||
new Newtonsoft.Json.Linq.JArray(
|
||||
Newtonsoft.Json.Linq.JToken.FromObject(new { instanceId = PipelineUtils.GetObjectId(a) }),
|
||||
Newtonsoft.Json.Linq.JToken.FromObject(new { instanceId = PipelineUtils.GetObjectId(b) })));
|
||||
|
||||
Assert.AreEqual(2, comp.Waypoints.Length, "Whole array should be set from a JSON array");
|
||||
Assert.AreSame(a, comp.Waypoints[0]);
|
||||
Assert.AreSame(b, comp.Waypoints[1]);
|
||||
|
||||
var read = SerializedFieldCommands.GetSerializedFields(ById(comp), "m_Waypoints");
|
||||
var json = Newtonsoft.Json.Linq.JObject.FromObject(read);
|
||||
Assert.AreEqual(2, (int)json["fields"][0]["arrayLength"], "get should report the array length");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(a);
|
||||
Object.DestroyImmediate(b);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetSerializedField_UnresolvableObjectRef_Throws()
|
||||
{
|
||||
// Previously an unresolved handle was silently dropped (no-op success); it must now throw.
|
||||
var comp = m_Go.AddComponent<ScriptCommandTestBehaviour>();
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
SerializedFieldCommands.SetSerializedField(ById(comp), "m_Target",
|
||||
Newtonsoft.Json.Linq.JToken.FromObject(new { instanceId = 999999999 })));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AUTHAPI-21 — value projection (format=value)
|
||||
|
||||
[Test]
|
||||
public void GetSerializedFields_FormatValue_SingleField_ReturnsRawValue()
|
||||
{
|
||||
var comp = m_Go.AddComponent<ScriptCommandTestBehaviour>();
|
||||
SerializedFieldCommands.SetSerializedField(ById(comp), "m_Speed",
|
||||
Newtonsoft.Json.Linq.JToken.FromObject(9));
|
||||
|
||||
// format=value returns the scalar directly rather than a { type, fields:[{...}] } descriptor.
|
||||
var read = SerializedFieldCommands.GetSerializedFields(ById(comp), "m_Speed", format: "value");
|
||||
|
||||
Assert.IsFalse(read is System.Collections.Generic.Dictionary<string, object>,
|
||||
"A single-field value read should return the raw value, not a map");
|
||||
Assert.AreEqual(9L, Convert.ToInt64(read), "value mode should return the raw field value");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSerializedFields_FormatValue_AllFields_ReturnsNameValueMap()
|
||||
{
|
||||
var comp = m_Go.AddComponent<ScriptCommandTestBehaviour>();
|
||||
SerializedFieldCommands.SetSerializedField(ById(comp), "m_Speed",
|
||||
Newtonsoft.Json.Linq.JToken.FromObject(3));
|
||||
|
||||
var read = SerializedFieldCommands.GetSerializedFields(ById(comp), format: "value");
|
||||
|
||||
var map = read as System.Collections.Generic.Dictionary<string, object>;
|
||||
Assert.IsNotNull(map, "value mode over all fields should return a name->value map");
|
||||
Assert.IsTrue(map.ContainsKey("m_Speed"), "map should be keyed by field name");
|
||||
Assert.AreEqual(3L, Convert.ToInt64(map["m_Speed"]), "map value should be the raw field value");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSerializedFields_DefaultFormat_StillReturnsDescriptor()
|
||||
{
|
||||
// Back-compat: with no format arg the full per-field descriptor is unchanged.
|
||||
var comp = m_Go.AddComponent<ScriptCommandTestBehaviour>();
|
||||
SerializedFieldCommands.SetSerializedField(ById(comp), "m_Speed",
|
||||
Newtonsoft.Json.Linq.JToken.FromObject(4));
|
||||
|
||||
var read = SerializedFieldCommands.GetSerializedFields(ById(comp), "m_Speed");
|
||||
var json = Newtonsoft.Json.Linq.JObject.FromObject(read);
|
||||
|
||||
Assert.AreEqual("m_Speed", (string)json["fields"][0]["name"], "Descriptor should carry the field name");
|
||||
Assert.AreEqual(4, (int)json["fields"][0]["value"], "Descriptor should carry the value");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An already-compiled MonoBehaviour living in the test assembly, used as the subject for the
|
||||
/// set/get/attach tests so they don't need a domain reload. Mirrors the kinds of fields an agent
|
||||
/// authors: a primitive, an enum, a Vector3, and a [SerializeField] object reference.
|
||||
/// </summary>
|
||||
class ScriptCommandTestBehaviour : MonoBehaviour
|
||||
{
|
||||
public enum EnemyMode { Passive, Aggressive, Patrol }
|
||||
|
||||
[SerializeField] private int m_Speed;
|
||||
[SerializeField] private EnemyMode m_Mode;
|
||||
[SerializeField] private Vector3 m_Offset;
|
||||
[SerializeField] private GameObject m_Target;
|
||||
[SerializeField] private GameObject[] m_Waypoints = Array.Empty<GameObject>();
|
||||
|
||||
// SerializedProperty paths use the serialized field names; with m_ private fields Unity's
|
||||
// serialized name is the field name itself ("m_Speed"). The tests address them via the
|
||||
// editor-friendly accessors below for assertions, and via the serialized name for the
|
||||
// command 'field' argument.
|
||||
public int Speed => m_Speed;
|
||||
public EnemyMode Mode => m_Mode;
|
||||
public Vector3 Offset => m_Offset;
|
||||
public GameObject Target => m_Target;
|
||||
public GameObject[] Waypoints => m_Waypoints;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A trivial ScriptableObject fixture used to exercise the asset-target path of the serialized-field
|
||||
/// commands (an asset is neither a Component nor a GameObject).
|
||||
/// </summary>
|
||||
class ScriptCommandTestScriptable : ScriptableObject
|
||||
{
|
||||
[SerializeField] private int m_Value;
|
||||
public int Value => m_Value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ad870a318fb8435abc8fc48cbf645d40
|
||||
Reference in New Issue
Block a user