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