using System.Text;
using Newtonsoft.Json;
using NUnit.Framework;
using Unity.Pipeline.Editor.Commands.Scripts;
using Unity.Pipeline.Models;
using Unity.Pipeline.Tests.Editor.Scripts;
using UnityEngine;
using Object = UnityEngine.Object;
namespace Unity.Pipeline.Tests.Editor
{
///
/// Guards the token-efficiency win from AUTHAPI-21 with the canonical "read one scalar" case.
/// Community feedback measured reading Transform.localPosition at ~823 B (indented JSON) and
/// ~190 B even via a single --field descriptor. With the lean envelope plus value projection
/// it collapses to a few dozen bytes; this test fails if the envelope silently regrows.
///
/// It serializes through — the exact path the server's
/// /api/exec handler uses — so the measured bytes track the real wire format.
///
class ResponseEnvelopeSizeTests
{
// The lean value-projection envelope for a float3 is
// {"success":true,"result":{"x":0.0,"y":0.0,"z":0.0}} — ~51 B at rest. The cap leaves headroom
// for float formatting while staying far below the old ~190 B / ~823 B envelopes.
private const int MaxCanonicalEnvelopeBytes = 96;
private GameObject m_Go;
[SetUp]
public void SetUp() => m_Go = new GameObject("AUTHAPI21_Subject");
[TearDown]
public void TearDown()
{
if (m_Go != null) Object.DestroyImmediate(m_Go);
m_Go = null;
}
private ObjectRef TransformRef() =>
new ObjectRef { InstanceId = PipelineUtils.GetObjectId(m_Go.transform) };
[Test]
public void ReadTransformLocalPosition_LeanValueEnvelope_StaysTiny()
{
// The canonical read: one scalar, value-projected.
var result = SerializedFieldCommands.GetSerializedFields(
TransformRef(), field: "m_LocalPosition", format: "value");
var json = ExecResponseSerializer.Serialize(
CommandExecutionResponse.CmdSuccess("get_serialized_fields", result), verbose: false);
var bytes = Encoding.UTF8.GetByteCount(json);
Assert.LessOrEqual(bytes, MaxCanonicalEnvelopeBytes,
$"Lean envelope for a Transform.localPosition read regrew to {bytes} B: {json}");
// The value and success survive; the always-on metadata does not.
StringAssert.Contains("\"success\":true", json);
StringAssert.DoesNotContain("executedAt", json);
StringAssert.DoesNotContain("\"command\"", json);
}
[Test]
public void ReadTransformLocalPosition_VerboseEnvelope_IsLargerThanLean()
{
var result = SerializedFieldCommands.GetSerializedFields(
TransformRef(), field: "m_LocalPosition", format: "value");
var response = CommandExecutionResponse.CmdSuccess("get_serialized_fields", result);
var leanBytes = Encoding.UTF8.GetByteCount(ExecResponseSerializer.Serialize(response, verbose: false));
var verboseBytes = Encoding.UTF8.GetByteCount(ExecResponseSerializer.Serialize(response, verbose: true));
Assert.Greater(verboseBytes, leanBytes,
"Verbose envelope should carry the metadata (executedAt/command/executionTimeMs) the lean one drops");
}
[Test]
public void VerboseEnvelope_RestoresMetadataOnNestedResponses()
{
// eval-family commands return a BaseResponse-derived object nested under `result`. The
// lean/verbose contract must apply to the whole graph: lean strips nested metadata too,
// and verbose restores it (previously the flag never reached nested objects, so verbose
// silently dropped the nested executedAt/executionTimeMs).
var nested = EvalResponse.EvalSuccess(42, executionTimeMs: 5);
var response = CommandExecutionResponse.CmdSuccess("eval", nested);
var lean = ExecResponseSerializer.Serialize(response, verbose: false);
var verbose = ExecResponseSerializer.Serialize(response, verbose: true);
StringAssert.DoesNotContain("executedAt", lean, "Lean should strip metadata from nested responses too");
StringAssert.Contains("executedAt", verbose, "Verbose must restore metadata on nested responses");
StringAssert.Contains("executionTimeMs", verbose, "Verbose must restore nested executionTimeMs");
}
[Test]
public void NullKeysInsideResult_KeptByDefault_DroppedOnlyWithOmitNulls()
{
// A scene-object AuthoringResult legitimately carries null asset fields. The result
// payload keeps explicit nulls in every default mode: an absent key would be
// indistinguishable from a nonexistent or misspelled one, so dropping payload nulls
// is strictly opt-in via the request's omitNulls flag (review feedback on AUTHAPI-21).
var result = new AuthoringResult { Type = "GameObject", HierarchyPath = "/AUTHAPI21_Subject" };
var response = CommandExecutionResponse.CmdSuccess("create_gameobject", result);
var lean = ExecResponseSerializer.Serialize(response, verbose: false);
var verbose = ExecResponseSerializer.Serialize(response, verbose: true);
var leanOmit = ExecResponseSerializer.Serialize(response, verbose: false, omitNulls: true);
var verboseOmit = ExecResponseSerializer.Serialize(response, verbose: true, omitNulls: true);
StringAssert.Contains("\"assetPath\":null", lean, "Lean must keep explicit nulls in the result payload");
StringAssert.Contains("\"assetPath\":null", verbose, "Verbose emits explicit nulls");
StringAssert.DoesNotContain("assetPath", leanOmit, "omitNulls drops payload nulls in lean mode");
StringAssert.DoesNotContain("assetPath", verboseOmit, "omitNulls drops payload nulls in verbose mode too (orthogonal axes)");
}
[Test]
public void EnvelopeNullKeys_OmittedInLean_WithoutTouchingPayloadNulls()
{
// The envelope's own nulls are redundant (success already disambiguates): a lean
// failure drops its null result key, a lean success drops error/errorDetails — while
// nulls INSIDE the payload survive untouched in the same reply.
var failure = ExecResponseSerializer.Serialize(
CommandExecutionResponse.CmdFailure("create_gameobject", "Command Execution Failed", "boom"), verbose: false);
var success = ExecResponseSerializer.Serialize(
CommandExecutionResponse.CmdSuccess("create_gameobject",
new { ok = true, optionalDetail = (string)null }), verbose: false);
StringAssert.Contains("\"success\":false", failure);
StringAssert.Contains("\"error\":\"Command Execution Failed\"", failure);
StringAssert.DoesNotContain("\"result\"", failure, "Lean failure omits its null result key");
StringAssert.DoesNotContain("\"error\"", success, "Lean success omits its null error keys");
StringAssert.DoesNotContain("\"message\"", success, "Lean success omits its null message key");
StringAssert.Contains("\"optionalDetail\":null", success,
"The payload's own nulls must remain intact in the very reply whose envelope nulls are stripped");
}
[Test]
public void DerivedResponsePayloadNulls_SurviveLean_OnlyEnvelopeKeysAreDropped()
{
// The lean null-dropping is enumerated per envelope key (message/error/errorDetails),
// NOT applied type-wide to everything a BaseResponse-derived type declares: eval's
// EvalResponse and run_tests' TestExecutionResponse declare PAYLOAD properties, and
// dropping those when null re-creates exactly the absent-vs-null ambiguity the
// payload rule exists to prevent.
var eval = EvalResponse.EvalSuccess(42); // Output stays null: the code logged nothing
var evalLean = ExecResponseSerializer.Serialize(eval, verbose: false);
StringAssert.Contains("\"output\":null", evalLean,
"EvalResponse.Output is payload — its null must stay explicit in lean mode");
StringAssert.DoesNotContain("\"message\"", evalLean,
"…while the envelope's own null message key is still dropped");
StringAssert.DoesNotContain("\"error\"", evalLean,
"…and its null error keys too");
// An async run_tests submission has no summary/results yet and no filter applied: a
// poller must be able to tell "not ready yet" (explicit null) from "not a field".
var tests = new TestExecutionResponse { Success = true, Summary = null, Results = null };
var testsLean = ExecResponseSerializer.Serialize(tests, verbose: false);
StringAssert.Contains("\"Summary\":null", testsLean,
"TestExecutionResponse.Summary is payload — null means 'not ready yet' to a poller");
StringAssert.Contains("\"FilterApplied\":null", testsLean,
"A null FilterApplied (no filter given) is payload state, not an envelope key");
}
[Test]
public void NullResult_OnSuccess_StaysExplicit_InEveryMode()
{
// A null result on SUCCESS is the command's actual value (a format="value" read of a
// genuinely-null field), not a droppable envelope null — every mode must emit it
// explicitly so the caller can tell "the value is null" from "there was no result".
var success = CommandExecutionResponse.CmdSuccess("get_serialized_fields", null);
StringAssert.Contains("\"result\":null", ExecResponseSerializer.Serialize(success, verbose: false),
"Lean success must carry an explicit null result");
StringAssert.Contains("\"result\":null", ExecResponseSerializer.Serialize(success, verbose: true),
"Verbose success must carry an explicit null result");
StringAssert.Contains("\"result\":null", ExecResponseSerializer.Serialize(success, verbose: false, omitNulls: true),
"Even omitNulls keeps the success result explicit — it is the command's value, not a payload column");
var failure = ExecResponseSerializer.Serialize(
CommandExecutionResponse.CmdFailure("get_serialized_fields", "Command Execution Failed", "boom"), verbose: false);
StringAssert.DoesNotContain("\"result\"", failure,
"On failure the result is structurally meaningless and stays omitted");
}
[Test]
public void GetSerializedFields_NullObjectReference_SerializesAsExplicitNullResult()
{
// End-to-end for the real-world case behind the rule above: a format="value" read of
// an unassigned object-reference field yields a null command result
// (ObjectResolver.Describe(null) returns null), which must reach the wire as
// "result":null through the exact serializer the exec endpoint uses.
var comp = m_Go.AddComponent();
var result = SerializedFieldCommands.GetSerializedFields(
new ObjectRef { InstanceId = PipelineUtils.GetObjectId(comp) }, field: "m_Target", format: "value");
Assert.IsNull(result, "Precondition: an unassigned object reference reads as a null value");
var lean = ExecResponseSerializer.Serialize(
CommandExecutionResponse.CmdSuccess("get_serialized_fields", result), verbose: false);
StringAssert.Contains("\"success\":true", lean);
StringAssert.Contains("\"result\":null", lean,
"A null field value must arrive as an explicit null result, never a missing key");
}
[Test]
public void NonExecSerialization_KeepsExecutedAt()
{
// The lean gating lives in ExecResponseSerializer's contract resolver, NOT on the models:
// other endpoints (401/403 rejections, status errors) serialize BaseResponse with default
// settings and must keep emitting executedAt.
var json = JsonConvert.SerializeObject(BaseResponse.Failure("Unauthorized", "details"));
StringAssert.Contains("executedAt", json,
"Default serialization of BaseResponse must be unaffected by the exec lean contract");
}
[Test]
public void Serialize_DoesNotMutateBody_ModesAreOrderIndependent()
{
var response = CommandExecutionResponse.CmdSuccess("noop", new { ok = true });
// verbose-then-lean and lean-then-verbose must agree call-for-call: the mode is carried by
// serializer settings only, never stored on the response instance.
var verboseFirst = ExecResponseSerializer.Serialize(response, verbose: true);
var leanSecond = ExecResponseSerializer.Serialize(response, verbose: false);
var verboseAgain = ExecResponseSerializer.Serialize(response, verbose: true);
Assert.AreEqual(verboseFirst, verboseAgain, "Repeated verbose serialization must be identical");
StringAssert.DoesNotContain("executedAt", leanSecond, "Lean output must not depend on prior calls");
}
// The argument-error envelope fields must behave exactly like Status/Retryable/Warnings:
// absent when null in all four modes, present when set in all four. They rely on a
// property-level NullValueHandling.Ignore beating the serializer setting — and the lean
// settings use NullValueHandling.Include, so forgetting the attribute would emit
// "argProblems":null on every single reply and silently undo part of the lean-envelope win.
/// The same response serialized through every lean/verbose x omitNulls combination.
private static string[] AllFourModes(CommandExecutionResponse response) => new[]
{
ExecResponseSerializer.Serialize(response, verbose: false, omitNulls: false),
ExecResponseSerializer.Serialize(response, verbose: false, omitNulls: true),
ExecResponseSerializer.Serialize(response, verbose: true, omitNulls: false),
ExecResponseSerializer.Serialize(response, verbose: true, omitNulls: true),
};
[Test]
public void SuccessEnvelope_OmitsTheArgumentFields_InAllFourModes()
{
var response = CommandExecutionResponse.CmdSuccess("ping", "pong");
foreach (var json in AllFourModes(response))
{
StringAssert.DoesNotContain("errorCode", json, $"leaked into: {json}");
StringAssert.DoesNotContain("argProblems", json, $"leaked into: {json}");
StringAssert.DoesNotContain("commandSchema", json, $"leaked into: {json}");
StringAssert.DoesNotContain("parameters", json, $"leaked into: {json}");
}
}
[Test]
public void GenericFailureEnvelope_OmitsTheArgumentFields_InAllFourModes()
{
var response = CommandExecutionResponse.CmdFailure("ping", "Boom", "it broke");
foreach (var json in AllFourModes(response))
{
StringAssert.DoesNotContain("errorCode", json, $"leaked into: {json}");
StringAssert.DoesNotContain("argProblems", json, $"leaked into: {json}");
StringAssert.DoesNotContain("commandSchema", json, $"leaked into: {json}");
}
}
[Test]
public void ArgumentErrorEnvelope_CarriesTheArgumentFields_InAllFourModes()
{
var problems = new System.Collections.Generic.List
{
new ArgProblem { Kind = ArgProblemKind.UnknownName, Name = "mesage", Suggestion = "message" },
};
var response = CommandExecutionResponse.CmdInvalidArgs("log_editor", "no such parameter",
problems, new Newtonsoft.Json.Linq.JObject { ["name"] = "log_editor" });
foreach (var json in AllFourModes(response))
{
StringAssert.Contains("INVALID_COMMAND_ARGS", json, $"missing from: {json}");
StringAssert.Contains("\"kind\":\"unknownName\"", json,
$"kind must serialize camelCase for the CLI. Got: {json}");
StringAssert.Contains("commandSchema", json, $"missing from: {json}");
}
}
[Test]
public void BoundParameterEcho_SurvivesEveryMode_WhenSet()
{
var response = CommandExecutionResponse.CmdSuccess("log_editor", "ok");
response.BoundParameters = new Newtonsoft.Json.Linq.JObject { ["message"] = "hi" };
foreach (var json in AllFourModes(response))
StringAssert.Contains("\"parameters\":{\"message\":\"hi\"}", json,
$"the echo is what four CLI output formats render. Got: {json}");
}
}
}