using System; using System.Collections.Generic; using System.IO; using System.Text.RegularExpressions; using NUnit.Framework; using Newtonsoft.Json.Linq; using Unity.Pipeline; using Unity.Pipeline.Editor.Commands.Batch; using Unity.Pipeline.Editor.Commands.GameObjects; using UnityEditor; using UnityEngine; using UnityEngine.TestTools; using Object = UnityEngine.Object; namespace Unity.Pipeline.Tests.Editor.Batch { /// /// Tests for the transactional batch command (AUTHAPI-27), driven end-to-end through an /// isolated (the ViaClient pattern). Covers the acceptance /// criteria: cross-op references resolve, a mid-batch failure atomically rolls back every prior /// op, a successful batch collapses into a single Undo group, dry-run validates without mutating, /// a bad reference errors clearly, on_error=continue applies independent ops without /// reverting, and the 200-op cap is enforced. /// class BatchCommandTests { private readonly List m_Spawned = new List(); private GameObject Track(GameObject go) { if (go != null) m_Spawned.Add(go); return go; } private static ObjectId? InstanceIdOf(JToken opResult) => opResult?["result"]?["instanceId"]?.ToObject(); private GameObject ResolveGameObject(JToken opResult) { var id = InstanceIdOf(opResult); if (!id.HasValue) return null; return Track(PipelineUtils.IdToObject(id.Value) as GameObject); } [TearDown] public void TearDown() { foreach (var go in m_Spawned) { if (go != null) Object.DestroyImmediate(go); } m_Spawned.Clear(); // Deliberately NO Undo.ClearAll() here: this suite runs inside the dogfood editor, and // wiping the global Undo stack would destroy the developer's own undo history. Per-test // state is cleaned up by destroying m_Spawned above; the batch Undo groups left on the // stack only reference those (now destroyed) test objects and are inert. } private static JObject Op(string command, JObject parameters = null, string id = null) { var op = new JObject { ["command"] = command }; if (id != null) op["id"] = id; op["params"] = parameters ?? new JObject(); return op; } [Test] public void Batch_CrossOpReferences_ResolveAndApply() { // AC: create_gameobject -> add_component -> set_serialized_field, where later ops use // $0.instanceId / $1.instanceId, executes in one round-trip with references resolved. using (var server = new PipelineTestServer()) { var operations = new JArray { Op("create_gameobject", new JObject { ["name"] = "BatchRef_Root" }), Op("add_component", new JObject { ["target"] = "$0.instanceId", ["type"] = "Rigidbody" }), Op("set_serialized_field", new JObject { ["target"] = "$1.instanceId", ["field"] = "m_Mass", ["value"] = new JValue(2.5f) }), }; var response = server.Execute("batch", new JObject { ["operations"] = operations }); Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}"); var batch = response.JsonResponse["result"]; Assert.AreEqual(3, batch["applied"]?.ToObject(), "all three ops should apply"); Assert.IsFalse(batch["reverted"]?.ToObject() ?? true, "a successful batch must not revert"); Assert.IsNotNull(batch["undoGroup"], "execution result should carry the batch Undo group"); var results = batch["results"] as JArray; Assert.AreEqual(3, results.Count); for (int i = 0; i < 3; i++) Assert.IsTrue(results[i]["success"]?.ToObject() ?? false, $"op {i} should succeed: {results[i]["error"]}"); // The reference chain wired a Rigidbody onto op 0's GameObject and set its mass. var root = ResolveGameObject(results[0]); Assert.IsNotNull(root, "op 0's GameObject should be resolvable from its returned instanceId"); var rb = root.GetComponent(); Assert.IsNotNull(rb, "$0.instanceId must resolve so add_component targets op 0's GameObject"); Assert.AreEqual(2.5f, rb.mass, 0.0001f, "$1.instanceId must resolve so set_serialized_field targets the added Rigidbody"); } } [Test] public void Batch_Transactional_MidBatchFailure_RevertsAllPriorOps() { // AC: an op fails mid-batch -> every prior op is reverted; scene equals pre-batch state. using (var server = new PipelineTestServer()) { var operations = new JArray { Op("create_gameobject", new JObject { ["name"] = "BatchRollback_A" }), Op("create_gameobject", new JObject { ["name"] = "BatchRollback_B" }), // Fails: unknown component type -> add_component throws before mutating. Op("add_component", new JObject { ["target"] = "$0.instanceId", ["type"] = "NoSuchTypeXYZ" }), }; // The failing sub-op logs an error through the shared command-dispatch path (batch // reuses it); the batch surfaces the failure structurally, but the log still fires. LogAssert.Expect(LogType.Error, new Regex("add_component' failed: Could not resolve component type 'NoSuchTypeXYZ'")); var response = server.Execute("batch", new JObject { ["operations"] = operations }); Assert.IsTrue(response.IsSuccess, $"batch itself should return HTTP success: {response.RawResponse}"); var batch = response.JsonResponse["result"]; Assert.IsTrue(batch["reverted"]?.ToObject() ?? false, "a transactional failure must revert"); Assert.AreEqual(2, batch["applied"]?.ToObject(), "two ops ran before the failure"); var results = batch["results"] as JArray; Assert.IsTrue(results[0]["success"]?.ToObject() ?? false); Assert.IsTrue(results[1]["success"]?.ToObject() ?? false); Assert.IsFalse(results[2]["success"]?.ToObject() ?? true, "op 2 must fail"); StringAssert.Contains("component type", results[2]["error"]?.ToString(), "the failing op's error should be reported"); // The two created objects must be gone (reverted), both by name and by their ids. Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchRollback_A").Count, "op 0's object must be reverted"); Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchRollback_B").Count, "op 1's object must be reverted"); var id0 = InstanceIdOf(results[0]); var id1 = InstanceIdOf(results[1]); Assert.IsTrue(id0.HasValue && id1.HasValue, "reverted ops still report the ids they had created"); Assert.IsNull(PipelineUtils.IdToObject(id0.Value), "op 0's object should no longer resolve"); Assert.IsNull(PipelineUtils.IdToObject(id1.Value), "op 1's object should no longer resolve"); } } [Test] public void Batch_SuccessfulBatch_CollapsesIntoSingleUndoGroup() { // AC: a successful batch shows exactly one Undo group -> a single Undo reverts the whole batch. using (var server = new PipelineTestServer()) { var operations = new JArray { Op("create_gameobject", new JObject { ["name"] = "BatchUndo_A" }), Op("create_gameobject", new JObject { ["name"] = "BatchUndo_B" }), }; var response = server.Execute("batch", new JObject { ["operations"] = operations }); Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}"); // Track the created objects so TearDown cleans them up even if the Undo below misbehaves. var results = response.JsonResponse["result"]["results"] as JArray; ResolveGameObject(results[0]); ResolveGameObject(results[1]); Assert.AreEqual(1, GameObjectCommands.FindGameObjects(name: "BatchUndo_A").Count); Assert.AreEqual(1, GameObjectCommands.FindGameObjects(name: "BatchUndo_B").Count); // One Undo must revert BOTH creations, proving the whole batch collapsed into one group. Undo.PerformUndo(); Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchUndo_A").Count, "a single Undo should revert the first op (batch is one group)"); Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchUndo_B").Count, "a single Undo should revert the second op too (batch is one group)"); } } [Test] public void Batch_DryRun_CatchesErrors_AndMutatesNothing() { // AC: dry_run catches unknown command, not_batchable, unknown param, and a bad reference, // with zero mutation. using (var server = new PipelineTestServer()) { var operations = new JArray { Op("create_gameobject", new JObject { ["name"] = "BatchDry_A" }), // valid Op("no_such_command_xyz"), // unknown command Op("build"), // not_batchable Op("create_gameobject", new JObject { ["bogus_param"] = "x" }), // unknown parameter Op("add_component", new JObject { ["target"] = "$9.instanceId", ["type"] = "Rigidbody" }), // bad ref }; var response = server.Execute("batch", new JObject { ["operations"] = operations, ["dry_run"] = true }); Assert.IsTrue(response.IsSuccess, $"dry_run should return HTTP success: {response.RawResponse}"); var batch = response.JsonResponse["result"]; Assert.IsTrue(batch["dryRun"]?.ToObject() ?? false, "result should be marked dryRun"); Assert.IsFalse(batch["valid"]?.ToObject() ?? true, "the batch has invalid ops"); Assert.AreEqual(0, batch["applied"]?.ToObject() ?? -1, "dry_run applies nothing"); var results = batch["results"] as JArray; Assert.IsTrue(results[0]["success"]?.ToObject() ?? false, "op 0 is valid"); StringAssert.Contains("unknown command", results[1]["error"]?.ToString()); StringAssert.Contains("not_batchable", results[2]["error"]?.ToString()); StringAssert.Contains("unknown parameter", results[3]["error"]?.ToString()); StringAssert.Contains("unknown operation", results[4]["error"]?.ToString()); // Zero mutation: the valid create op must NOT have run. Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchDry_A").Count, "dry_run must not create anything"); } } [Test] public void Batch_BadReference_ErrorsClearly_AndReverts() { // AC: a bad reference errors clearly. Here op 1 references a nonexistent op; under the // default transactional/abort policy op 0 is reverted. using (var server = new PipelineTestServer()) { var operations = new JArray { Op("create_gameobject", new JObject { ["name"] = "BatchBadRef_X" }), Op("add_component", new JObject { ["target"] = "$5.instanceId", ["type"] = "Rigidbody" }), }; var response = server.Execute("batch", new JObject { ["operations"] = operations }); Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}"); var batch = response.JsonResponse["result"]; var results = batch["results"] as JArray; Assert.IsFalse(results[1]["success"]?.ToObject() ?? true, "the referencing op must fail"); var error = results[1]["error"]?.ToString(); StringAssert.Contains("$5.instanceId", error, "the error should name the offending reference"); StringAssert.Contains("unknown operation", error, "and explain why it is invalid"); Assert.IsTrue(batch["reverted"]?.ToObject() ?? false, "the prior op must be reverted"); Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchBadRef_X").Count, "op 0 must be reverted after the bad reference"); } } [Test] public void Batch_OnErrorContinue_AppliesIndependentOps_NoRevert() { // AC: on_error=continue collects failures, applies independent ops, reports reverted:false. using (var server = new PipelineTestServer()) { var operations = new JArray { Op("create_gameobject", new JObject { ["name"] = "BatchCont_A" }), Op("add_component", new JObject { ["target"] = "$0.instanceId", ["type"] = "NoSuchTypeXYZ" }), // fails Op("create_gameobject", new JObject { ["name"] = "BatchCont_C" }), // independent }; LogAssert.Expect(LogType.Error, new Regex("add_component' failed: Could not resolve component type 'NoSuchTypeXYZ'")); var response = server.Execute("batch", new JObject { ["operations"] = operations, ["on_error"] = "continue" }); Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}"); var batch = response.JsonResponse["result"]; Assert.IsFalse(batch["reverted"]?.ToObject() ?? true, "continue must not revert"); Assert.IsFalse(batch["transactional"]?.ToObject() ?? true, "continue forces transactional=false"); Assert.AreEqual(2, batch["applied"]?.ToObject(), "the two independent creates apply"); var results = batch["results"] as JArray; Assert.IsTrue(results[0]["success"]?.ToObject() ?? false); Assert.IsFalse(results[1]["success"]?.ToObject() ?? true, "the middle op fails"); Assert.IsTrue(results[2]["success"]?.ToObject() ?? false, "a later independent op still runs"); // Both independent objects must survive (no rollback). Assert.IsNotNull(ResolveGameObject(results[0]), "op 0's object should survive"); Assert.IsNotNull(ResolveGameObject(results[2]), "op 2's object should survive"); } } [Test] public void Batch_ExceedsOperationCap_ReturnsError() { // AC: the 200-op cap is enforced with a structured error (checked before anything runs). using (var server = new PipelineTestServer()) { var operations = new JArray(); for (int i = 0; i < 201; i++) operations.Add(Op("create_gameobject", new JObject { ["name"] = "BatchCap_" + i })); LogAssert.Expect(LogType.Error, new Regex("batch supports at most 200 operations")); var response = server.Execute("batch", new JObject { ["operations"] = operations, ["dry_run"] = true }); Assert.IsFalse(response.IsSuccess, "a batch over the op cap must be rejected"); StringAssert.Contains("200", response.RawResponse, "the error should state the 200-op cap"); Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchCap_0").Count, "an over-cap batch must create nothing"); } } [Test] public void Batch_AsyncCommand_NotBatchable_AtDryRunAndExecution() { // C1: run_tests/list_tests are async Task commands completed by EditorApplication.update // callbacks, which cannot fire while the batch blocks the main thread — awaiting them // inline would freeze the editor permanently. They must be rejected, not dispatched. using (var server = new PipelineTestServer()) { var operations = new JArray { Op("run_tests", new JObject { ["mode"] = "EditMode" }), Op("list_tests"), }; // Dry run flags both without executing anything. var response = server.Execute("batch", new JObject { ["operations"] = operations, ["dry_run"] = true }); Assert.IsTrue(response.IsSuccess, $"dry_run should return HTTP success: {response.RawResponse}"); var results = response.JsonResponse["result"]["results"] as JArray; foreach (var opResult in results) { Assert.IsFalse(opResult["success"]?.ToObject() ?? true, "async ops must fail validation"); StringAssert.Contains("not_batchable", opResult["error"]?.ToString()); StringAssert.Contains("async", opResult["error"]?.ToString()); } // Execution rejects them the same way — and returns instead of hanging. response = server.Execute("batch", new JObject { ["operations"] = new JArray { Op("run_tests") } }); Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}"); var exec = response.JsonResponse["result"]; Assert.AreEqual(0, exec["applied"]?.ToObject(), "nothing must be dispatched"); StringAssert.Contains("not_batchable", exec["results"][0]["error"]?.ToString()); } } [Test] public void Batch_Transactional_RejectsNonRevertibleCommand_NonTransactionalAllowsIt() { // C2: delete_asset mutates the AssetDatabase, which Undo cannot roll back — inside a // transactional batch "reverted: true" would be a lie, so it is rejected. With // transactional:false it runs and reports revertible:false. const string assetPath = "Assets/BatchTests_DeleteTarget.txt"; File.WriteAllText(assetPath, "batch delete target"); AssetDatabase.ImportAsset(assetPath); try { using (var server = new PipelineTestServer()) { var operations = new JArray { Op("delete_asset", new JObject { ["asset"] = assetPath, ["confirm"] = true }), }; // Dry run under the default transactional=true flags it. var response = server.Execute("batch", new JObject { ["operations"] = operations, ["dry_run"] = true }); var dry = response.JsonResponse["result"]; Assert.IsFalse(dry["valid"]?.ToObject() ?? true); StringAssert.Contains("not_batchable_transactional", dry["results"][0]["error"]?.ToString()); // Execution under transactional=true rejects it too; the asset survives. response = server.Execute("batch", new JObject { ["operations"] = operations }); var exec = response.JsonResponse["result"]; Assert.IsFalse(exec["results"][0]["success"]?.ToObject() ?? true); StringAssert.Contains("not_batchable_transactional", exec["results"][0]["error"]?.ToString()); Assert.IsNotNull(AssetDatabase.LoadMainAssetAtPath(assetPath), "a rejected delete_asset must not touch the asset"); // transactional:false allows it — with an honest revertible:false on the op. response = server.Execute("batch", new JObject { ["operations"] = operations, ["transactional"] = false }); exec = response.JsonResponse["result"]; Assert.IsTrue(exec["results"][0]["success"]?.ToObject() ?? false, $"transactional:false must allow delete_asset: {exec["results"][0]["error"]}"); Assert.IsFalse(exec["results"][0]["revertible"]?.ToObject() ?? true, "the op must report revertible:false"); Assert.IsNull(AssetDatabase.LoadMainAssetAtPath(assetPath), "the asset must be deleted"); } } finally { if (AssetDatabase.LoadMainAssetAtPath(assetPath) != null) AssetDatabase.DeleteAsset(assetPath); } } [Test] public void Batch_OpenScene_AlwaysRejected_EvenNonTransactional() { // C2: open_scene/create_scene CLEAR the Editor Undo stack, destroying the batch's ability // to revert anything before them — they are rejected regardless of the transactional flag. using (var server = new PipelineTestServer()) { var operations = new JArray { Op("open_scene", new JObject { ["path"] = "Assets/Nope.unity" }), }; var response = server.Execute("batch", new JObject { ["operations"] = operations, ["dry_run"] = true }); var dry = response.JsonResponse["result"]; StringAssert.Contains("not_batchable", dry["results"][0]["error"]?.ToString()); StringAssert.Contains("Undo stack", dry["results"][0]["error"]?.ToString()); response = server.Execute("batch", new JObject { ["operations"] = operations, ["transactional"] = false }); var exec = response.JsonResponse["result"]; Assert.IsFalse(exec["results"][0]["success"]?.ToObject() ?? true); StringAssert.Contains("not_batchable", exec["results"][0]["error"]?.ToString()); } } [Test] public void Batch_FirstOpPartialMutation_IsRolledBack() { // I1: an op that mutates and THEN throws leaves partial Undo-registered state even when // it is the first (and only) op — the transactional rollback must run with applied == 0. // set_transform applies 'position' before it parses the invalid 'rotation'. using (var server = new PipelineTestServer()) { var go = Track(new GameObject("BatchPartial_Target")); go.transform.localPosition = Vector3.zero; var operations = new JArray { Op("set_transform", new JObject { // Version-stable id: GetInstanceID() is obsolete-as-error from 6000.4. ["target"] = PipelineUtils.GetObjectId(go).RawValue, ["position"] = new JArray(5f, 6f, 7f), ["rotation"] = new JArray(1f, 2f), // invalid: 2 components -> throws after position applied }), }; LogAssert.Expect(LogType.Error, new Regex("set_transform' failed: 'rotation' must have exactly 3 components")); var response = server.Execute("batch", new JObject { ["operations"] = operations }); Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}"); var batch = response.JsonResponse["result"]; Assert.AreEqual(0, batch["applied"]?.ToObject(), "the only op failed"); Assert.IsTrue(batch["reverted"]?.ToObject() ?? false, "a transactional failure must revert even when applied == 0"); Assert.AreEqual(0f, go.transform.localPosition.magnitude, 0.0001f, "the failing op's partial position change must be reverted"); } } [Test] public void Batch_TimeBudgetExceeded_SkipsRemainingOps_AndRevertsTransactional() { // I2: the cooperative time budget is checked before each op after the first. Budget 0 is // exhausted immediately after op 0, so ops 1..n are skipped and the transactional batch // rolls back with a batch-level error. using (var server = new PipelineTestServer()) { var operations = new JArray { Op("create_gameobject", new JObject { ["name"] = "BatchBudget_A" }), Op("create_gameobject", new JObject { ["name"] = "BatchBudget_B" }), Op("create_gameobject", new JObject { ["name"] = "BatchBudget_C" }), }; var response = server.Execute("batch", new JObject { ["operations"] = operations, ["time_budget_ms"] = 0 }); Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}"); var batch = response.JsonResponse["result"]; StringAssert.Contains("batch time budget exceeded", batch["error"]?.ToString()); Assert.AreEqual(1, batch["applied"]?.ToObject(), "only op 0 runs on a zero budget"); Assert.IsTrue(batch["reverted"]?.ToObject() ?? false, "budget abort is a transactional failure"); var results = batch["results"] as JArray; Assert.AreEqual(3, results.Count); Assert.IsTrue(results[0]["success"]?.ToObject() ?? false, "op 0 always gets to run"); Assert.IsTrue(results[1]["skipped"]?.ToObject() ?? false, "op 1 must be skipped"); Assert.IsTrue(results[2]["skipped"]?.ToObject() ?? false, "op 2 must be skipped"); Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchBudget_A").Count, "op 0 must be reverted by the budget-abort rollback"); Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchBudget_B").Count); } } [Test] public void Batch_ReferenceToFailedOp_ErrorsClearly() { // I3: under on_error=continue an op can fail without stopping the batch; a later $ref // into the failed op must error explicitly instead of silently resolving to null. using (var server = new PipelineTestServer()) { var operations = new JArray { Op("add_component", new JObject { ["target"] = "/NoSuchObjectXYZ_00", ["type"] = "Rigidbody" }), // fails Op("rename_gameobject", new JObject { ["target"] = "$0.instanceId", ["name"] = "Renamed" }), }; LogAssert.Expect(LogType.Error, new Regex("add_component' failed:")); var response = server.Execute("batch", new JObject { ["operations"] = operations, ["on_error"] = "continue" }); Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}"); var results = response.JsonResponse["result"]["results"] as JArray; Assert.IsFalse(results[0]["success"]?.ToObject() ?? true, "op 0 must fail"); Assert.IsFalse(results[1]["success"]?.ToObject() ?? true, "the referencing op must fail"); var error = results[1]["error"]?.ToString(); StringAssert.Contains("$0.instanceId", error, "the error should name the offending reference"); StringAssert.Contains("failed; its result cannot be referenced", error, "and say the referenced op failed"); } } [Test] public void Resolver_ReferenceToVoidResult_Errors_WholeValueAndPath() { // I3: a SUCCEEDED op with a null result (void command) is distinct from a failed op — // referencing it errors with "returned no result", for whole-value and path refs alike. var operations = new List { new BatchOperationInput { Command = "noop_a" }, new BatchOperationInput { Command = "noop_b" }, }; var outcomes = new List { new BatchOperationResult { Command = "noop_a", Success = true }, new BatchOperationResult { Command = "noop_b" }, }; var results = new JToken[] { JValue.CreateNull(), null }; var idToIndex = new Dictionary(); var wholeRef = new JObject { ["value"] = "$0" }; var ex = Assert.Throws(() => BatchReferenceResolver.Resolve(wholeRef, 1, operations, idToIndex, results, outcomes)); StringAssert.Contains("returned no result", ex.Message); var pathRef = new JObject { ["value"] = "$0.instanceId" }; ex = Assert.Throws(() => BatchReferenceResolver.Resolve(pathRef, 1, operations, idToIndex, results, outcomes)); StringAssert.Contains("returned no result", ex.Message); } [Test] public void Resolver_ReferenceToFailedOp_Errors_WholeValueAndPath() { // I3: whole-value refs into a FAILED op must error just like path refs (the end-to-end // test above covers the path form; this pins the whole-value form). var operations = new List { new BatchOperationInput { Command = "noop_a" }, new BatchOperationInput { Command = "noop_b" }, }; var outcomes = new List { new BatchOperationResult { Command = "noop_a", Success = false, Error = "boom" }, new BatchOperationResult { Command = "noop_b" }, }; var results = new JToken[] { null, null }; var idToIndex = new Dictionary(); var wholeRef = new JObject { ["value"] = "$0" }; var ex = Assert.Throws(() => BatchReferenceResolver.Resolve(wholeRef, 1, operations, idToIndex, results, outcomes)); StringAssert.Contains("failed; its result cannot be referenced", ex.Message); } [Test] public void Batch_RuntimeOnlyCommand_NotBatchable() { // I5: runtime-only commands (Player server surface) are discoverable via the registry but // must not be dispatchable through an editor-server batch. using (var server = new PipelineTestServer()) { var response = server.Execute("batch", new JObject { ["operations"] = new JArray { Op("set_timescale", new JObject { ["scale"] = 0.5f }) }, ["dry_run"] = true }); var dry = response.JsonResponse["result"]; Assert.IsFalse(dry["valid"]?.ToObject() ?? true); var error = dry["results"][0]["error"]?.ToString(); StringAssert.Contains("not_batchable", error); StringAssert.Contains("runtime-only", error); } } [Test] public void Batch_MenuCommand_NotBatchable() { // I6: menu items can open modal dialogs, which would wedge an unattended batch. using (var server = new PipelineTestServer()) { var response = server.Execute("batch", new JObject { ["operations"] = new JArray { Op("menu", new JObject { ["path"] = "File/Save" }) }, ["dry_run"] = true }); var dry = response.JsonResponse["result"]; Assert.IsFalse(dry["valid"]?.ToObject() ?? true); StringAssert.Contains("not_batchable", dry["results"][0]["error"]?.ToString()); } } [Test] public void Batch_NumericOpId_Rejected() { // M3: a purely numeric id would be ambiguous with a 0-based index selector in "$". using (var server = new PipelineTestServer()) { var operations = new JArray { Op("create_gameobject", new JObject { ["name"] = "BatchId_A" }, id: "0"), }; LogAssert.Expect(LogType.Error, new Regex("ids must match")); var response = server.Execute("batch", new JObject { ["operations"] = operations, ["dry_run"] = true }); Assert.IsFalse(response.IsSuccess, "a numeric op id must be rejected outright"); StringAssert.Contains("ids must match", response.RawResponse); } } [Test] public void Batch_DollarDollar_EscapesToLiteralDollar() { // M7: "$$..." escapes a literal '$' — the op receives "$literal", not a reference. using (var server = new PipelineTestServer()) { var operations = new JArray { Op("create_gameobject", new JObject { ["name"] = "$$literal" }), }; var response = server.Execute("batch", new JObject { ["operations"] = operations }); Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}"); var results = response.JsonResponse["result"]["results"] as JArray; var go = ResolveGameObject(results[0]); Assert.IsNotNull(go, "op 0's GameObject should be resolvable"); Assert.AreEqual("$literal", go.name, "\"$$literal\" must unescape to the literal \"$literal\""); } } [Test] public void Batch_IdBasedReference_Resolves() { // M7: references can select the target op by its id, not only by index. using (var server = new PipelineTestServer()) { var operations = new JArray { Op("create_gameobject", new JObject { ["name"] = "BatchIdRef_Root" }, id: "root"), Op("add_component", new JObject { ["target"] = "$root.instanceId", ["type"] = "Rigidbody" }), }; var response = server.Execute("batch", new JObject { ["operations"] = operations }); Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}"); var batch = response.JsonResponse["result"]; Assert.AreEqual(2, batch["applied"]?.ToObject(), $"both ops should apply: {batch["results"]}"); var root = ResolveGameObject((batch["results"] as JArray)[0]); Assert.IsNotNull(root); Assert.IsNotNull(root.GetComponent(), "$root.instanceId must resolve by op id"); } } [Test] public void Batch_ResultFields_ProjectsOpResult() { // M7: result_fields keeps only the requested result fields for the op (context economy). using (var server = new PipelineTestServer()) { var operations = new JArray { Op("create_gameobject", new JObject { ["name"] = "BatchProject_A" }), }; var response = server.Execute("batch", new JObject { ["operations"] = operations, ["result_fields"] = new JObject { ["0"] = new JArray("instanceId") } }); Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}"); var results = response.JsonResponse["result"]["results"] as JArray; var projected = results[0]["result"] as JObject; Assert.IsNotNull(projected, "the projected result should be an object"); Assert.IsNotNull(projected["instanceId"], "the requested field must be kept"); Assert.IsNull(projected["hierarchyPath"], "unrequested fields must be dropped"); Assert.IsNull(projected["globalId"], "unrequested fields must be dropped"); // Cleanup: the projection kept instanceId, so the object is still resolvable. Assert.IsNotNull(ResolveGameObject(results[0])); } } [Test] public void Batch_OversizedOpResult_Truncated_ButReferencesStillResolve() { // M7: an op result over the per-op cap is replaced by a truncation marker in the envelope, // but later $refs resolve against the FULL (untruncated) result. const string bigPath = "Assets/BatchTests_BigFile.txt"; File.WriteAllText(bigPath, new string('x', 20000)); // > 16 KiB serialized try { using (var server = new PipelineTestServer()) { var operations = new JArray { Op("read_text_file", new JObject { ["path"] = bigPath }), // References a scalar of the oversized result — must still resolve. Op("create_gameobject", new JObject { ["name"] = "$0.assetPath" }), }; var response = server.Execute("batch", new JObject { ["operations"] = operations }); Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}"); var results = response.JsonResponse["result"]["results"] as JArray; Assert.IsTrue(results[0]["resultTruncated"]?.ToObject() ?? false, "the oversized read_text_file result must be truncated"); Assert.IsTrue(results[0]["result"]?["resultTruncated"]?.ToObject() ?? false, "the result must be replaced by the truncation marker"); Assert.IsTrue(results[1]["success"]?.ToObject() ?? false, $"the referencing op must still succeed: {results[1]["error"]}"); var go = ResolveGameObject(results[1]); Assert.IsNotNull(go); Assert.AreEqual(bigPath, go.name, "$0.assetPath must resolve against the untruncated result"); } } finally { if (File.Exists(bigPath)) File.Delete(bigPath); if (File.Exists(bigPath + ".meta")) File.Delete(bigPath + ".meta"); } } [Test] public void Batch_NestedBatch_Rejected() { // M7: batch-in-batch is excluded (recursion/auditing hazard). using (var server = new PipelineTestServer()) { var response = server.Execute("batch", new JObject { ["operations"] = new JArray { Op("batch", new JObject { ["operations"] = new JArray() }) }, ["dry_run"] = true }); var dry = response.JsonResponse["result"]; Assert.IsFalse(dry["valid"]?.ToObject() ?? true); StringAssert.Contains("not_batchable", dry["results"][0]["error"]?.ToString()); } } [Test] public void Batch_NonTransactional_Abort_KeepsAppliedOps_SkipsRest() { // M7: transactional:false + on_error=abort stops at the failing op but keeps what was // already applied (no rollback), and marks the rest skipped. using (var server = new PipelineTestServer()) { var operations = new JArray { Op("create_gameobject", new JObject { ["name"] = "BatchNoTx_A" }), Op("add_component", new JObject { ["target"] = "$0.instanceId", ["type"] = "NoSuchTypeXYZ" }), // fails Op("create_gameobject", new JObject { ["name"] = "BatchNoTx_C" }), }; LogAssert.Expect(LogType.Error, new Regex("add_component' failed: Could not resolve component type 'NoSuchTypeXYZ'")); var response = server.Execute("batch", new JObject { ["operations"] = operations, ["transactional"] = false }); Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}"); var batch = response.JsonResponse["result"]; Assert.IsFalse(batch["reverted"]?.ToObject() ?? true, "transactional:false must not revert"); Assert.AreEqual(1, batch["applied"]?.ToObject()); var results = batch["results"] as JArray; Assert.IsTrue(results[0]["success"]?.ToObject() ?? false); Assert.IsFalse(results[1]["success"]?.ToObject() ?? true, "op 1 must fail"); Assert.IsTrue(results[2]["skipped"]?.ToObject() ?? false, "op 2 must be skipped (abort)"); Assert.IsNotNull(ResolveGameObject(results[0]), "op 0's object must survive (no rollback)"); Assert.AreEqual(0, GameObjectCommands.FindGameObjects(name: "BatchNoTx_C").Count, "op 2 never ran"); } } [Test] public void Batch_InvalidResultFieldsPath_DoesNotFailTheAppliedOp() { // Review finding: a malformed result_fields JSONPath threw AFTER the op had mutated // state, and the outer catch flipped the op's Success to false — rolling back a // succeeded op under on_error=abort. The projection must report the bad path instead. using (var server = new PipelineTestServer()) { var operations = new JArray { Op("create_gameobject", new JObject { ["name"] = "BatchProj_BadPath" }) }; var response = server.Execute("batch", new JObject { ["operations"] = operations, ["result_fields"] = new JObject { ["0"] = new JArray("foo[") } }); Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}"); var batch = response.JsonResponse["result"]; var results = batch["results"] as JArray; Assert.IsTrue(results[0]["success"]?.ToObject() ?? false, "a malformed projection path must not flip an already-applied op to failure"); Assert.IsFalse(batch["reverted"]?.ToObject() ?? true, "and must not trigger a rollback of the succeeded op"); StringAssert.Contains("foo[", results[0]["result"]?["invalidResultFields"]?[0]?.ToString(), "the bad path is reported inside the projection"); var survivors = GameObjectCommands.FindGameObjects(name: "BatchProj_BadPath"); Assert.AreEqual(1, survivors.Count, "the op's mutation must survive"); Track(GameObject.Find("BatchProj_BadPath")); } } [Test] public void Batch_TransactionalRevert_LeavesUndoStackClean() { // Review finding: after Undo.RevertAllDownToGroup discarded the batch group, the // enclosing AuthoringUndoScope still collapsed that stale group id on dispose. The // scope is now canceled after a revert; the contract this pins: the next PerformUndo // must undo the USER's previous operation, not be absorbed by a stray batch group. using (var server = new PipelineTestServer()) { Undo.IncrementCurrentGroup(); var marker = Track(new GameObject("BatchUndoStack_Marker")); Undo.RegisterCreatedObjectUndo(marker, "create marker"); Undo.IncrementCurrentGroup(); var operations = new JArray { Op("create_gameobject", new JObject { ["name"] = "BatchUndoStack_Tx" }), Op("add_component", new JObject { ["target"] = "$0.instanceId", ["type"] = "NoSuchTypeXYZ" }), }; LogAssert.Expect(LogType.Error, new Regex("add_component' failed: Could not resolve component type 'NoSuchTypeXYZ'")); var response = server.Execute("batch", new JObject { ["operations"] = operations }); Assert.IsTrue(response.IsSuccess, $"batch should return HTTP success: {response.RawResponse}"); Assert.IsTrue(response.JsonResponse["result"]["reverted"]?.ToObject() ?? false, "precondition: the transactional batch reverted"); Undo.PerformUndo(); Assert.IsTrue(marker == null, "PerformUndo after a reverted batch must undo the previous user operation " + "(the marker creation) — a surviving marker means a stray batch group absorbed it"); } } [Test] public void ProjectResult_MeasuresUtf8Bytes_NotUtf16Chars() { // Review finding: the per-op cap compared UTF-16 char counts while the wire is UTF-8 — // a CJK payload at ~12k chars is ~36 KB on the wire and sailed under the 16 KiB cap. var op = new BatchOperationInput { Command = "noop" }; var opResult = new BatchOperationResult(); var budget = int.MaxValue; // isolate the per-op cap from the aggregate budget var cjk = new string('\u597D', 12_000); // 12k chars, ~36 KB UTF-8 var projected = BatchCommand.ProjectResult(cjk, JToken.FromObject(cjk), op, 0, null, opResult, ref budget); Assert.IsTrue(opResult.ResultTruncated == true, "a payload under the char count but over the UTF-8 byte cap must be truncated"); var marker = (JObject)JToken.FromObject(projected); Assert.Greater(marker["length"]?.ToObject() ?? 0, 16 * 1024, "length reports wire bytes"); } [Test] public void ProjectResult_AggregateBudget_ElidesLaterResults() { // Review finding: nothing bounded the SUM of per-op results (200 ops x 16 KiB each is a // ~3.2 MB reply). Once the aggregate budget is exhausted, later results become markers. var op = new BatchOperationInput { Command = "noop" }; var budget = BatchCommand.MaxBatchResultBytes; var big = new string('a', 15_000); // under the per-op cap; ~17 of these exhaust the budget object last = null; BatchOperationResult lastResult = null; var truncatedAt = -1; for (var i = 0; i < 30; i++) { lastResult = new BatchOperationResult(); last = BatchCommand.ProjectResult(big, JToken.FromObject(big), op, i, null, lastResult, ref budget); if (lastResult.ResultTruncated == true) { truncatedAt = i; break; } } Assert.GreaterOrEqual(truncatedAt, 2, "several results must fit before the budget trips"); Assert.AreNotEqual(-1, truncatedAt, "the aggregate budget must eventually trip"); StringAssert.Contains("aggregate result budget", ((JObject)JToken.FromObject(last))["error"]?.ToString(), "the marker explains the aggregate elision"); } } }