1334 lines
67 KiB
C#
1334 lines
67 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using NUnit.Framework;
|
|
using Newtonsoft.Json.Linq;
|
|
using Unity.Pipeline;
|
|
using Unity.Pipeline.Commands;
|
|
using Unity.Pipeline.Editor;
|
|
using Unity.Pipeline.Editor.Commands;
|
|
using Unity.Pipeline.Models;
|
|
using UnityEditor;
|
|
using UnityEngine;
|
|
|
|
namespace Unity.Pipeline.Tests.Editor
|
|
{
|
|
/// <summary>
|
|
/// Tests for the wait_for / wait_status / wait_cancel commands (AUTHAPI-25): server-side
|
|
/// condition waiting that replaces client polling loops.
|
|
///
|
|
/// The core cases are exercised by calling the command/evaluator directly on the test's main
|
|
/// thread (deterministic, no HTTP/GPU dependency); a couple of ViaClient tests then confirm the
|
|
/// full HTTP + off-main-thread marshaling path. Async waits are driven by pumping the registry's
|
|
/// Tick() directly so they resolve without depending on the live editor update loop.
|
|
///
|
|
/// NOTE: authored but not yet executed against a live editor (coordinated run pending).
|
|
/// </summary>
|
|
class WaitForCommandTests
|
|
{
|
|
private ProbeBehaviourAsset m_Probe;
|
|
|
|
[SetUp]
|
|
public void SetUp()
|
|
{
|
|
CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
|
|
WaitRegistry.ResetForTests();
|
|
WaitForProbe.Reset();
|
|
WaitForCommands.s_CaptureAction = FakeCapture;
|
|
s_FakeCaptureCount = 0;
|
|
}
|
|
|
|
[TearDown]
|
|
public void TearDown()
|
|
{
|
|
WaitRegistry.ResetForTests();
|
|
WaitForProbe.Reset();
|
|
WaitForCommands.ResetCaptureActionForTests();
|
|
if (m_Probe != null)
|
|
{
|
|
UnityEngine.Object.DestroyImmediate(m_Probe);
|
|
m_Probe = null;
|
|
}
|
|
}
|
|
|
|
private static int s_FakeCaptureCount;
|
|
|
|
private static WaitCaptureResult FakeCapture(WaitCaptureInput input)
|
|
{
|
|
s_FakeCaptureCount++;
|
|
return new WaitCaptureResult { SavedPath = "Assets/__fake_wait_capture.png", Source = input?.View ?? "game" };
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------------------
|
|
// Discovery / registration
|
|
// ----------------------------------------------------------------------------------------
|
|
|
|
[Test]
|
|
public void WaitCommands_AreDiscovered_WithWaitTagAndRequiredCondition()
|
|
{
|
|
var commands = CommandRegistry.DiscoverCommands().ToList();
|
|
|
|
var waitFor = commands.FirstOrDefault(c => c.Name == "wait_for");
|
|
Assert.IsNotNull(waitFor, "wait_for should be discovered");
|
|
CollectionAssert.Contains(waitFor.Tags, "wait", "wait_for should carry the 'wait' tag");
|
|
Assert.IsFalse(waitFor.MainThreadRequired,
|
|
"wait_for must not require the main thread (it marshals per-poll instead of holding it)");
|
|
var condition = waitFor.Parameters.FirstOrDefault(p => p.Name == "condition");
|
|
Assert.IsNotNull(condition, "wait_for should expose a 'condition' parameter");
|
|
Assert.IsTrue(condition.Required, "'condition' must be required");
|
|
Assert.IsNotNull(waitFor.Parameters.FirstOrDefault(p => p.Name == "tolerate_missing"),
|
|
"wait_for should expose 'tolerate_missing'");
|
|
|
|
Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "wait_status"), "wait_status should be discovered");
|
|
Assert.IsNotNull(commands.FirstOrDefault(c => c.Name == "wait_cancel"), "wait_cancel should be discovered");
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------------------
|
|
// Sync: already true / becomes true / timeout
|
|
// ----------------------------------------------------------------------------------------
|
|
|
|
[Test]
|
|
public void WaitFor_ConditionAlreadyTrue_ReturnsMetImmediately()
|
|
{
|
|
var result = (WaitResult)WaitForCommands.WaitFor(
|
|
StaticCondition("AlwaysTrue", "equals", true), timeoutS: 5, pollIntervalMs: 50);
|
|
|
|
Assert.IsTrue(result.Met, "should be met immediately");
|
|
Assert.IsFalse(result.TimedOut, "must not time out");
|
|
Assert.AreEqual("met", result.State);
|
|
Assert.GreaterOrEqual(result.FramesObserved, 1);
|
|
}
|
|
|
|
[Test]
|
|
public void WaitFor_EnumConditionFlips_ReturnsWithinTwoPollIntervals()
|
|
{
|
|
// Latency contract: an enum-valued member flips at a known time; the wait must return
|
|
// within flip + 2 * poll_interval + slack (CI-safe bound, well under the timeout).
|
|
WaitForProbe.FlipAt = DateTime.UtcNow.AddMilliseconds(200);
|
|
|
|
var result = (WaitResult)WaitForCommands.WaitFor(
|
|
StaticCondition("TimedState", "equals", "Done"), timeoutS: 5, pollIntervalMs: 100);
|
|
|
|
Assert.IsTrue(result.Met, "should meet the condition after the enum flips");
|
|
Assert.IsFalse(result.TimedOut);
|
|
Assert.AreEqual("Done", result.Value?.ToString());
|
|
Assert.GreaterOrEqual(result.ElapsedMs, 150, "should not resolve before the flip time");
|
|
Assert.LessOrEqual(result.ElapsedMs, 1500,
|
|
"should return within flip (200ms) + 2 poll intervals (200ms) + generous CI slack");
|
|
}
|
|
|
|
[Test]
|
|
public void WaitFor_TimeoutElapsesWithoutCondition_ReturnsTimedOutWithLastValue()
|
|
{
|
|
var result = (WaitResult)WaitForCommands.WaitFor(
|
|
StaticCondition("AlwaysFalse", "equals", true), timeoutS: 0.3, pollIntervalMs: 50);
|
|
|
|
Assert.IsFalse(result.Met, "must not be met");
|
|
Assert.IsTrue(result.TimedOut, "must report a timeout");
|
|
Assert.AreEqual("timedOut", result.State);
|
|
Assert.IsNotNull(result.Value, "the last observed value must be reported on timeout");
|
|
Assert.GreaterOrEqual(result.FramesObserved, 1);
|
|
}
|
|
|
|
[Test]
|
|
public void WaitFor_PollIntervalLargerThanTimeout_StillHonorsTimeout()
|
|
{
|
|
// The sleep is bounded by the remaining budget (and the interval clamp), so a huge
|
|
// poll_interval_ms must not stretch a 1-second timeout.
|
|
var result = (WaitResult)WaitForCommands.WaitFor(
|
|
StaticCondition("AlwaysFalse", "equals", true), timeoutS: 1, pollIntervalMs: 60000);
|
|
|
|
Assert.IsTrue(result.TimedOut, "must time out");
|
|
Assert.GreaterOrEqual(result.ElapsedMs, 900, "must wait roughly the requested second");
|
|
Assert.Less(result.ElapsedMs, 2500, "a 60s poll interval must not stretch a 1s timeout");
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------------------
|
|
// Invalid / missing parameters
|
|
// ----------------------------------------------------------------------------------------
|
|
|
|
private static IEnumerable<TestCaseData> InvalidWaitForInputs()
|
|
{
|
|
yield return new TestCaseData((TestDelegate)(() => WaitForCommands.WaitFor(null)), null)
|
|
.SetName("WaitFor_NullCondition_Throws");
|
|
yield return new TestCaseData((TestDelegate)(() => WaitForCommands.WaitFor(
|
|
new WaitConditionInput { Member = "", Op = "equals", Value = true })), null)
|
|
.SetName("WaitFor_MissingMember_Throws");
|
|
yield return new TestCaseData((TestDelegate)(() => WaitForCommands.WaitFor(
|
|
new WaitConditionInput { Member = "X.Y", Op = "startsWith", Value = "a" })), null)
|
|
.SetName("WaitFor_InvalidOperator_Throws");
|
|
// equals/notEquals accept an explicit null operand (wait-until-destroyed); the
|
|
// ordering/substring ops still require a value.
|
|
yield return new TestCaseData((TestDelegate)(() => WaitForCommands.WaitFor(
|
|
new WaitConditionInput { Member = "X.Y", Op = "greaterThan", Value = null })), null)
|
|
.SetName("WaitFor_MissingValueForComparisonOp_Throws");
|
|
// Omitting the operand entirely must be rejected even for equals — only an
|
|
// EXPLICIT JSON null is a valid equality operand (wait-until-destroyed).
|
|
yield return new TestCaseData((TestDelegate)(() => WaitForCommands.WaitFor(
|
|
new WaitConditionInput { Member = "X.Y", Op = "equals" })), "required")
|
|
.SetName("WaitFor_OmittedValueForEquals_Throws");
|
|
yield return new TestCaseData((TestDelegate)(() => WaitForCommands.WaitFor(
|
|
StaticCondition("AlwaysTrue", "equals", true), timeoutS: double.NaN)), "timeout_s")
|
|
.SetName("WaitFor_NaNTimeout_Throws");
|
|
yield return new TestCaseData((TestDelegate)(() => WaitForCommands.WaitFor(
|
|
StaticCondition("AlwaysTrue", "equals", true), pollIntervalMs: double.NaN)), "poll_interval_ms")
|
|
.SetName("WaitFor_NaNPollInterval_Throws");
|
|
yield return new TestCaseData((TestDelegate)(() => WaitForCommands.WaitFor(
|
|
StaticCondition("AlwaysTrue", "equals", true), pollIntervalMs: double.PositiveInfinity)), "poll_interval_ms")
|
|
.SetName("WaitFor_InfinitePollInterval_Throws");
|
|
}
|
|
|
|
[TestCaseSource(nameof(InvalidWaitForInputs))]
|
|
public void WaitFor_InvalidInput_Throws(TestDelegate call, string messageContains)
|
|
{
|
|
var ex = Assert.Throws<ArgumentException>(call);
|
|
if (messageContains != null)
|
|
StringAssert.Contains(messageContains, ex.Message);
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------------------
|
|
// Operator coverage (evaluator-level, deterministic)
|
|
// ----------------------------------------------------------------------------------------
|
|
|
|
[TestCase("equals", ProbeState.Done, "Done", typeof(ProbeState), true, TestName = "Evaluate_Equals_OnEnum_True")]
|
|
[TestCase("equals", ProbeState.Idle, "Done", typeof(ProbeState), false, TestName = "Evaluate_Equals_OnEnum_False")]
|
|
[TestCase("equals", 5, 5, typeof(int), true, TestName = "Evaluate_Equals_OnInt")]
|
|
[TestCase("notEquals", 5, 6, typeof(int), true, TestName = "Evaluate_NotEquals_OnInt")]
|
|
[TestCase("equals", "hi", "hi", typeof(string), true, TestName = "Evaluate_Equals_OnString")]
|
|
[TestCase("contains", "hello", "ell", typeof(string), true, TestName = "Evaluate_Contains_OnString_True")]
|
|
[TestCase("contains", "hello", "xyz", typeof(string), false, TestName = "Evaluate_Contains_OnString_False")]
|
|
[TestCase("greaterThan", 0.75f, 0.5, typeof(float), true, TestName = "Evaluate_GreaterThan_OnFloat_True")]
|
|
[TestCase("greaterThan", 0.25f, 0.5, typeof(float), false, TestName = "Evaluate_GreaterThan_OnFloat_False")]
|
|
[TestCase("lessThan", 0.25f, 0.5, typeof(float), true, TestName = "Evaluate_LessThan_OnFloat")]
|
|
public void Evaluate_Operator_ProducesExpected(string op, object current, object operand, Type memberType, bool expected)
|
|
{
|
|
Assert.AreEqual(expected, WaitConditionEvaluator.Evaluate(op, current, operand, memberType, null, false, out var err));
|
|
Assert.IsNull(err);
|
|
}
|
|
|
|
[Test]
|
|
public void Evaluate_Equals_FloatAgainstDoubleOperand_ComparesAtFloatPrecision()
|
|
{
|
|
// With an object-typed member the operand stays a double (0.9), while the observed
|
|
// value is a float (0.9f). Widening the float to double manufactures noise digits, so
|
|
// a double-precision compare never matches — the comparison must drop to float precision.
|
|
Assert.IsTrue(WaitConditionEvaluator.Evaluate("equals", 0.9f, (object)0.9d, typeof(object), null, false, out var err),
|
|
"0.9f must equal the operand 0.9 at float precision");
|
|
Assert.IsNull(err);
|
|
Assert.IsFalse(WaitConditionEvaluator.Evaluate("equals", 0.8f, (object)0.9d, typeof(object), null, false, out _));
|
|
}
|
|
|
|
[Test]
|
|
public void WaitFor_Equals_OnFloatMember_PointNine_Matches()
|
|
{
|
|
WaitForProbe.Ratio = 0.9f;
|
|
|
|
var result = (WaitResult)WaitForCommands.WaitFor(
|
|
StaticCondition("Ratio", "equals", 0.9), timeoutS: 2, pollIntervalMs: 50);
|
|
|
|
Assert.IsTrue(result.Met, "equals on a float member with value 0.9 must match");
|
|
Assert.AreEqual("met", result.State);
|
|
}
|
|
|
|
[Test]
|
|
public void Evaluate_Changed_MetOnFirstChangeFromInitial()
|
|
{
|
|
// First observation: no baseline yet -> not "changed".
|
|
Assert.IsFalse(WaitConditionEvaluator.Evaluate("changed", false, null, typeof(bool), null, false, out _));
|
|
// Later observation differs from the initial value -> changed.
|
|
Assert.IsTrue(WaitConditionEvaluator.Evaluate("changed", true, null, typeof(bool), false, true, out _));
|
|
// Same as initial -> not changed.
|
|
Assert.IsFalse(WaitConditionEvaluator.Evaluate("changed", false, null, typeof(bool), false, true, out _));
|
|
}
|
|
|
|
[Test]
|
|
public void Evaluate_ContainsOnNonString_ReturnsError()
|
|
{
|
|
Assert.IsFalse(WaitConditionEvaluator.Evaluate("contains", 5, "5", typeof(int), null, false, out var err));
|
|
Assert.IsNotNull(err);
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------------------
|
|
// Member resolution
|
|
// ----------------------------------------------------------------------------------------
|
|
|
|
[Test]
|
|
public void ReadMember_StaticPath_ResolvesValue()
|
|
{
|
|
WaitForProbe.Count = 7;
|
|
var value = WaitConditionEvaluator.ReadMember(
|
|
new WaitConditionInput { Member = "Unity.Pipeline.Tests.Editor.WaitForProbe.Count" }, out var type);
|
|
|
|
Assert.AreEqual(7, value);
|
|
Assert.AreEqual(typeof(int), type);
|
|
}
|
|
|
|
[Test]
|
|
public void ReadMember_PlanCachesTypeResolution_AcrossPolls()
|
|
{
|
|
// Repeated reads through one plan must keep returning fresh values (the cached type +
|
|
// memoized member chain must not freeze the observed value).
|
|
var plan = new WaitConditionPlan(
|
|
new WaitConditionInput { Member = "Unity.Pipeline.Tests.Editor.WaitForProbe.Count" });
|
|
|
|
WaitForProbe.Count = 1;
|
|
Assert.AreEqual(1, WaitConditionEvaluator.ReadMember(plan, out _));
|
|
Assert.IsNotNull(plan.StaticType, "the resolved type must be cached on the plan");
|
|
|
|
WaitForProbe.Count = 2;
|
|
Assert.AreEqual(2, WaitConditionEvaluator.ReadMember(plan, out _),
|
|
"a cached plan must still observe the live value");
|
|
}
|
|
|
|
[Test]
|
|
public void ReadMember_WriteOnlyMember_IsRejected()
|
|
{
|
|
Assert.Throws<WaitEvaluationException>(() =>
|
|
WaitConditionEvaluator.ReadMember(
|
|
new WaitConditionInput { Member = "Unity.Pipeline.Tests.Editor.WaitForProbe.WriteOnly" }, out _));
|
|
}
|
|
|
|
[Test]
|
|
public void ReadMember_UnknownMember_IsRejected()
|
|
{
|
|
Assert.Throws<WaitEvaluationException>(() =>
|
|
WaitConditionEvaluator.ReadMember(
|
|
new WaitConditionInput { Member = "Unity.Pipeline.Tests.Editor.WaitForProbe.DoesNotExist" }, out _));
|
|
}
|
|
|
|
[Test]
|
|
public void ReadMember_ViaFindType_ResolvesInstanceMember()
|
|
{
|
|
m_Probe = ScriptableObject.CreateInstance<ProbeBehaviourAsset>();
|
|
m_Probe.Ratio = 0.9f;
|
|
|
|
var value = WaitConditionEvaluator.ReadMember(new WaitConditionInput
|
|
{
|
|
FindType = typeof(ProbeBehaviourAsset).FullName,
|
|
Member = "Ratio"
|
|
}, out var type);
|
|
|
|
Assert.AreEqual(0.9f, (float)value, 0.0001f);
|
|
Assert.AreEqual(typeof(float), type);
|
|
}
|
|
|
|
[Test]
|
|
public void ReadMember_ViaTargetInstanceId_ResolvesInstanceMember()
|
|
{
|
|
m_Probe = ScriptableObject.CreateInstance<ProbeBehaviourAsset>();
|
|
m_Probe.Label = "victory";
|
|
|
|
var value = WaitConditionEvaluator.ReadMember(new WaitConditionInput
|
|
{
|
|
Target = new ObjectRef { InstanceId = PipelineUtils.GetObjectId(m_Probe) },
|
|
Member = "Label"
|
|
}, out var type);
|
|
|
|
Assert.AreEqual("victory", value);
|
|
Assert.AreEqual(typeof(string), type);
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------------------
|
|
// Review regressions: fractional budgets, structured getter failures, timeout parity
|
|
// ----------------------------------------------------------------------------------------
|
|
|
|
[Test]
|
|
public void WaitFor_FractionalTimeout_TerminatesInsteadOfSpinning()
|
|
{
|
|
// A fractional timeout_s (non-integer milliseconds) used to truncate the sleep slice
|
|
// to 0 once the remaining budget dropped below 1 ms — an infinite spin that never
|
|
// released the exec gate. The wait must time out and return.
|
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
|
var result = (WaitResult)WaitForCommands.WaitFor(new WaitConditionInput
|
|
{
|
|
Member = "UnityEngine.Application.isPlaying",
|
|
Op = "equals",
|
|
Value = true
|
|
}, timeoutS: 0.1234, pollIntervalMs: 50);
|
|
sw.Stop();
|
|
|
|
Assert.IsTrue(result.TimedOut, "the never-true condition must time out");
|
|
Assert.Less(sw.ElapsedMilliseconds, 5000,
|
|
"a ~123.4ms budget must terminate promptly, never spin on a 0ms slice");
|
|
}
|
|
|
|
[Test]
|
|
public void WaitFor_DestroyedObjectInPath_FailsStructured()
|
|
{
|
|
// A destroyed UnityEngine.Object mid-path passes an object-typed null check (fake
|
|
// null); the traversal must fail with a structured evaluation error, not a raw
|
|
// MissingReferenceException from the next getter.
|
|
m_Probe = ScriptableObject.CreateInstance<ProbeBehaviourAsset>();
|
|
var go = new GameObject("WaitFor_DestroyedRef");
|
|
m_Probe.Target = go;
|
|
UnityEngine.Object.DestroyImmediate(go);
|
|
|
|
var result = (WaitResult)WaitForCommands.WaitFor(new WaitConditionInput
|
|
{
|
|
Target = new ObjectRef { InstanceId = PipelineUtils.GetObjectId(m_Probe) },
|
|
Member = "Target.name",
|
|
Op = "equals",
|
|
Value = "x"
|
|
}, timeoutS: 5, pollIntervalMs: 50);
|
|
|
|
Assert.AreEqual("failed", result.State, $"expected structured failure, got {result.State}: {result.Error}");
|
|
StringAssert.Contains("destroyed Unity object", result.Error);
|
|
}
|
|
|
|
[Test]
|
|
public void WaitFor_ThrowingMemberGetter_FailsStructured()
|
|
{
|
|
// A member getter that throws must resolve the wait as a structured failure (with the
|
|
// real exception surfaced), never escape WaitFor raw or vanish in the async Tick path.
|
|
m_Probe = ScriptableObject.CreateInstance<ProbeBehaviourAsset>();
|
|
|
|
var result = (WaitResult)WaitForCommands.WaitFor(new WaitConditionInput
|
|
{
|
|
Target = new ObjectRef { InstanceId = PipelineUtils.GetObjectId(m_Probe) },
|
|
Member = "Explodes",
|
|
Op = "equals",
|
|
Value = "x"
|
|
}, timeoutS: 5, pollIntervalMs: 50);
|
|
|
|
Assert.AreEqual("failed", result.State, $"expected structured failure, got {result.State}: {result.Error}");
|
|
StringAssert.Contains("InvalidOperationException", result.Error);
|
|
StringAssert.Contains("probe getter boom", result.Error);
|
|
}
|
|
|
|
[Test]
|
|
public void WaitFor_Async_SubTickTimeout_ObservesConditionAtLeastOnce()
|
|
{
|
|
// Parity with the sync path: an async wait whose budget expires before the first Tick
|
|
// must still poll once before resolving timedOut, so the result carries a real
|
|
// value/framesObserved instead of null/0.
|
|
var submit = (WaitResult)WaitForCommands.WaitFor(new WaitConditionInput
|
|
{
|
|
Member = "UnityEngine.Time.timeScale",
|
|
Op = "equals",
|
|
Value = -1
|
|
}, timeoutS: 0.001, pollIntervalMs: 16, async: true);
|
|
|
|
Thread.Sleep(10); // let the tiny budget expire before the first tick
|
|
WaitRegistry.Tick();
|
|
|
|
var status = (WaitResult)WaitForCommands.WaitStatus(submit.WaitId);
|
|
Assert.AreEqual("timedOut", status.State);
|
|
Assert.GreaterOrEqual(status.FramesObserved, 1,
|
|
"the condition must be observed at least once before a timeout resolves");
|
|
Assert.IsNotNull(status.Value, "the last observed value must be reported");
|
|
}
|
|
|
|
[Test]
|
|
public void WaitFor_Interruption_OverwritesStaleToleratedError()
|
|
{
|
|
// A tolerated resolution failure records its message on the job; a later interruption
|
|
// must overwrite it — wait_status should report why the wait ENDED, not the last retry.
|
|
var submit = (WaitResult)WaitForCommands.WaitFor(new WaitConditionInput
|
|
{
|
|
Member = "No.Such.Type.Member",
|
|
Op = "equals",
|
|
Value = 1
|
|
}, timeoutS: 60, pollIntervalMs: 16, tolerateMissing: true, async: true);
|
|
|
|
WaitRegistry.Tick(); // records the tolerated resolution error
|
|
var pending = (WaitResult)WaitForCommands.WaitStatus(submit.WaitId);
|
|
Assert.AreEqual("pending", pending.State, "precondition: tolerated failure keeps the wait pending");
|
|
|
|
WaitRegistry.SimulatePlayModeChangeForTests(PlayModeStateChange.ExitingPlayMode);
|
|
|
|
var status = (WaitResult)WaitForCommands.WaitStatus(submit.WaitId);
|
|
Assert.AreEqual("interrupted", status.State);
|
|
StringAssert.Contains("interrupted", status.Error,
|
|
"the interruption cause must win over the stale resolution message");
|
|
StringAssert.DoesNotContain("Could not resolve", status.Error);
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------------------
|
|
// Review round 2: on_met failures, final-look parity, validation, destroyed terminals
|
|
// ----------------------------------------------------------------------------------------
|
|
|
|
[Test]
|
|
public void WaitFor_OnMetFollowupThrows_ReportsMetWithOnMetError()
|
|
{
|
|
// The condition HELD but the follow-up failed: the wait must resolve met with the
|
|
// follow-up failure on its own onMetError field (never on error, which docs define as
|
|
// the reason a wait FAILED) — and an unknown capture view must throw, not silently
|
|
// capture the Game View.
|
|
WaitForCommands.ResetCaptureActionForTests(); // an earlier test may have faked capture
|
|
|
|
var result = (WaitResult)WaitForCommands.WaitFor(new WaitConditionInput
|
|
{
|
|
Member = "UnityEngine.Application.isPlaying",
|
|
Op = "equals",
|
|
Value = false
|
|
}, timeoutS: 5, pollIntervalMs: 50, onMet: new WaitOnMetInput
|
|
{
|
|
Capture = new WaitCaptureInput { View = "seen" }
|
|
});
|
|
|
|
Assert.AreEqual("met", result.State, $"the condition held; got {result.State}: {result.Error}");
|
|
Assert.IsTrue(result.Met);
|
|
Assert.IsNull(result.Error, "error stays reserved for failed waits");
|
|
Assert.IsNotNull(result.OnMetError, "the follow-up failure must be reported");
|
|
StringAssert.Contains("Unknown view", result.OnMetError);
|
|
Assert.IsNull(result.Capture, "no capture was produced");
|
|
}
|
|
|
|
[Test]
|
|
public void WaitFor_Async_ConditionFlipsJustBeforeDeadline_ResolvesMetOnFinalLook()
|
|
{
|
|
// Full parity with the sync loop: the deadline tick takes a final look BEFORE
|
|
// resolving timedOut, so a transition that lands between the last scheduled poll and
|
|
// the deadline is caught, not dropped.
|
|
var submit = (WaitResult)WaitForCommands.WaitFor(new WaitConditionInput
|
|
{
|
|
Member = "UnityEngine.Time.timeScale",
|
|
Op = "equals",
|
|
Value = 0.66
|
|
}, timeoutS: 0.05, pollIntervalMs: 5000, async: true);
|
|
|
|
try
|
|
{
|
|
Time.timeScale = 0.66f; // flips true after registration, before any scheduled poll
|
|
Thread.Sleep(60); // let the budget expire
|
|
WaitRegistry.Tick();
|
|
|
|
var status = (WaitResult)WaitForCommands.WaitStatus(submit.WaitId);
|
|
Assert.AreEqual("met", status.State,
|
|
"the deadline tick must observe the flipped condition, not resolve timedOut");
|
|
}
|
|
finally
|
|
{
|
|
Time.timeScale = 1f;
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void WaitFor_TargetAndFindTypeTogether_Rejected()
|
|
{
|
|
m_Probe = ScriptableObject.CreateInstance<ProbeBehaviourAsset>();
|
|
var ex = Assert.Throws<ArgumentException>(() => WaitForCommands.WaitFor(new WaitConditionInput
|
|
{
|
|
Target = new ObjectRef { InstanceId = PipelineUtils.GetObjectId(m_Probe) },
|
|
FindType = typeof(ProbeBehaviourAsset).FullName,
|
|
Member = "Label",
|
|
Op = "equals",
|
|
Value = "x"
|
|
}, timeoutS: 1));
|
|
StringAssert.Contains("mutually exclusive", ex.Message);
|
|
}
|
|
|
|
[Test]
|
|
public void WaitFor_DestroyedTerminalValue_ObservesNull()
|
|
{
|
|
// A destroyed Unity object as the LEAF value is observed as null (Unity's own ==
|
|
// semantics), so "wait until destroyed" is expressible as { op: equals, value: null }.
|
|
// (Traversal THROUGH a destroyed object still fails structured — covered elsewhere.)
|
|
m_Probe = ScriptableObject.CreateInstance<ProbeBehaviourAsset>();
|
|
var go = new GameObject("WaitFor_DestroyedTerminal");
|
|
m_Probe.Target = go;
|
|
UnityEngine.Object.DestroyImmediate(go);
|
|
|
|
var result = (WaitResult)WaitForCommands.WaitFor(new WaitConditionInput
|
|
{
|
|
Target = new ObjectRef { InstanceId = PipelineUtils.GetObjectId(m_Probe) },
|
|
Member = "Target",
|
|
Op = "equals",
|
|
Value = JValue.CreateNull() // EXPLICIT null: an omitted value is rejected
|
|
}, timeoutS: 5, pollIntervalMs: 50);
|
|
|
|
Assert.AreEqual("met", result.State,
|
|
$"a destroyed terminal value must observe as null, got {result.State}: {result.Error}");
|
|
}
|
|
|
|
[Test]
|
|
public void ResolveType_AmbiguousSimpleName_BindsDeterministicallyWithNote()
|
|
{
|
|
// "Debug" exists in UnityEngine and System.Diagnostics (at least): the bare name must
|
|
// bind deterministically (lowest full name) and surface an ambiguity note; the
|
|
// fully-qualified name resolves silently.
|
|
var ambiguous = WaitConditionEvaluator.ResolveType("Debug", out var note);
|
|
Assert.IsNotNull(ambiguous, "a simple-name match must still resolve");
|
|
Assert.IsNotNull(note, "multiple matches must surface an ambiguity note");
|
|
StringAssert.Contains("matched", note);
|
|
StringAssert.Contains("fully-qualified", note);
|
|
|
|
var exact = WaitConditionEvaluator.ResolveType("UnityEngine.Debug", out var exactNote);
|
|
Assert.AreEqual(typeof(Debug), exact);
|
|
Assert.IsNull(exactNote, "an exact full name is unambiguous");
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------------------
|
|
// Review round 4: tolerate scope, cancel-vs-met race, integer precision
|
|
// ----------------------------------------------------------------------------------------
|
|
|
|
[Test]
|
|
public void WaitFor_TolerateMissing_GetterThrow_StillFailsStructured()
|
|
{
|
|
// tolerate_missing is scoped to structured RESOLUTION failures; a member getter that
|
|
// throws is a real failure and must not be retried into a timeout that buries the
|
|
// exception.
|
|
m_Probe = ScriptableObject.CreateInstance<ProbeBehaviourAsset>();
|
|
|
|
var result = (WaitResult)WaitForCommands.WaitFor(new WaitConditionInput
|
|
{
|
|
Target = new ObjectRef { InstanceId = PipelineUtils.GetObjectId(m_Probe) },
|
|
Member = "Explodes",
|
|
Op = "equals",
|
|
Value = "x"
|
|
}, timeoutS: 5, pollIntervalMs: 50, tolerateMissing: true);
|
|
|
|
Assert.AreEqual("failed", result.State,
|
|
$"a throwing getter must fail even under tolerate_missing, got {result.State}: {result.Error}");
|
|
StringAssert.Contains("InvalidOperationException", result.Error);
|
|
}
|
|
|
|
[Test]
|
|
public void WaitFor_CancelLandingDuringOnMetFollowup_StaysMet()
|
|
{
|
|
// Review round 5: the job claims the met transition (m_Resolving) BEFORE running the
|
|
// on_met follow-up, precisely so a cancel landing DURING it (this simulates the
|
|
// request-thread race) is too late to retroactively undo a side effect (the capture)
|
|
// that already ran for real. It must resolve met with that capture retained, and the
|
|
// racing wait_cancel call — serviced WHILE the follow-up is still in flight, before the
|
|
// final commit — must report the honest state at that instant ("pending": the cancel
|
|
// had no effect and the job had not yet committed), never a fabricated "canceled".
|
|
var submit = (WaitResult)WaitForCommands.WaitFor(new WaitConditionInput
|
|
{
|
|
Member = "UnityEngine.Application.isPlaying",
|
|
Op = "equals",
|
|
Value = false
|
|
}, timeoutS: 30, pollIntervalMs: 16, async: true,
|
|
onMet: new WaitOnMetInput { Capture = new WaitCaptureInput() });
|
|
|
|
WaitResult raceCancelReply = null;
|
|
try
|
|
{
|
|
WaitForCommands.s_CaptureAction = _ =>
|
|
{
|
|
// Simulates the request-thread cancel arriving mid-follow-up.
|
|
raceCancelReply = (WaitResult)WaitForCommands.WaitCancel(submit.WaitId);
|
|
return new WaitCaptureResult { Width = 1, Height = 1 };
|
|
};
|
|
|
|
WaitRegistry.Tick();
|
|
|
|
var status = (WaitResult)WaitForCommands.WaitStatus(submit.WaitId);
|
|
Assert.AreEqual("met", status.State,
|
|
"the job already committed to the follow-up before the cancel landed");
|
|
Assert.IsNotNull(status.Capture, "the capture that actually ran must not be silently discarded");
|
|
Assert.AreEqual("pending", raceCancelReply.State,
|
|
"a cancel serviced before the in-flight follow-up commits must report the honest " +
|
|
"pending state, not a fabricated canceled");
|
|
}
|
|
finally
|
|
{
|
|
WaitForCommands.ResetCaptureActionForTests();
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void WaitFor_CancelLandingBeforeOnMetFollowup_StaysCanceled()
|
|
{
|
|
// The companion race: a cancel that lands BEFORE the job claims the met transition
|
|
// (i.e. before PollOnce even starts the follow-up) must still win — cancel is only
|
|
// "too late" once the follow-up's side effects are already committed to running.
|
|
var submit = (WaitResult)WaitForCommands.WaitFor(new WaitConditionInput
|
|
{
|
|
Member = "UnityEngine.Application.isPlaying",
|
|
Op = "equals",
|
|
Value = false
|
|
}, timeoutS: 30, pollIntervalMs: 16, async: true,
|
|
onMet: new WaitOnMetInput { Capture = new WaitCaptureInput() });
|
|
|
|
WaitForCommands.WaitCancel(submit.WaitId);
|
|
WaitRegistry.Tick();
|
|
|
|
var status = (WaitResult)WaitForCommands.WaitStatus(submit.WaitId);
|
|
Assert.AreEqual("canceled", status.State, "a cancel that landed before the poll must still win");
|
|
Assert.IsNull(status.Capture, "a canceled wait must never have run the follow-up");
|
|
}
|
|
|
|
[TestCase("equals", false, TestName = "Evaluate_LongEquals_BeyondDoublePrecision_IsExact")]
|
|
[TestCase("notEquals", true, TestName = "Evaluate_LongNotEquals_BeyondDoublePrecision_IsExact")]
|
|
public void Evaluate_LongComparison_BeyondDoublePrecision(string op, bool expected)
|
|
{
|
|
// 9007199254740993 and 9007199254740992 collapse to the same double (2^53); the
|
|
// comparison must stay exact for integral operands.
|
|
Assert.AreEqual(expected, WaitConditionEvaluator.Evaluate(
|
|
op, 9007199254740993L, 9007199254740992L, typeof(long), null, false, out var err));
|
|
Assert.IsNull(err);
|
|
}
|
|
|
|
[Test]
|
|
public void Evaluate_LongOrdering_BeyondDoublePrecision_IsExact()
|
|
{
|
|
Assert.IsTrue(WaitConditionEvaluator.Evaluate(
|
|
"greaterThan", 9007199254740993L, 9007199254740992L, typeof(long), null, false, out var err));
|
|
Assert.IsNull(err);
|
|
Assert.IsFalse(WaitConditionEvaluator.Evaluate(
|
|
"lessThan", 9007199254740993L, 9007199254740992L, typeof(long), null, false, out _));
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------------------
|
|
// Review round 5: tolerated evaluation errors
|
|
// ----------------------------------------------------------------------------------------
|
|
|
|
[Test]
|
|
public void WaitFor_TolerateMissing_EvaluationError_RetriesUntilTimeout()
|
|
{
|
|
// An ordering op against a member that is still null is "cannot compare YET" — under
|
|
// tolerate_missing it must retry (the wait-until-nullable-field-is-set case), with the
|
|
// last evaluation error surfaced on timeout, not fail on the first poll.
|
|
m_Probe = ScriptableObject.CreateInstance<ProbeBehaviourAsset>(); // Target stays null
|
|
|
|
var result = (WaitResult)WaitForCommands.WaitFor(new WaitConditionInput
|
|
{
|
|
Target = new ObjectRef { InstanceId = PipelineUtils.GetObjectId(m_Probe) },
|
|
Member = "Target",
|
|
Op = "greaterThan",
|
|
Value = 1
|
|
}, timeoutS: 0.4, pollIntervalMs: 50, tolerateMissing: true);
|
|
|
|
Assert.AreEqual("timedOut", result.State,
|
|
$"a tolerated evaluation error must run out the clock, got {result.State}: {result.Error}");
|
|
Assert.Greater(result.FramesObserved, 1, "the condition must have been retried");
|
|
Assert.IsNotNull(result.Error, "the last evaluation error is surfaced on timeout");
|
|
}
|
|
|
|
[Test]
|
|
public void WaitFor_EvaluationError_WithoutTolerate_FailsFast()
|
|
{
|
|
m_Probe = ScriptableObject.CreateInstance<ProbeBehaviourAsset>();
|
|
|
|
var result = (WaitResult)WaitForCommands.WaitFor(new WaitConditionInput
|
|
{
|
|
Target = new ObjectRef { InstanceId = PipelineUtils.GetObjectId(m_Probe) },
|
|
Member = "Target",
|
|
Op = "greaterThan",
|
|
Value = 1
|
|
}, timeoutS: 5, pollIntervalMs: 50);
|
|
|
|
Assert.AreEqual("failed", result.State, "an evaluation error is terminal by default");
|
|
Assert.IsFalse(result.TimedOut);
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------------------
|
|
// tolerate_missing: fail-fast vs retry-until-timeout
|
|
// ----------------------------------------------------------------------------------------
|
|
|
|
[Test]
|
|
public void WaitFor_UnresolvableMember_FailsFast_ByDefault()
|
|
{
|
|
var result = (WaitResult)WaitForCommands.WaitFor(new WaitConditionInput
|
|
{
|
|
Member = "No.Such.Type.Member",
|
|
Op = "equals",
|
|
Value = true
|
|
}, timeoutS: 5, pollIntervalMs: 50);
|
|
|
|
Assert.AreEqual("failed", result.State, "resolution failure must be terminal by default");
|
|
Assert.IsFalse(result.Met);
|
|
Assert.IsFalse(result.TimedOut, "a resolution failure must not masquerade as a timeout");
|
|
Assert.IsNotNull(result.Error);
|
|
}
|
|
|
|
[Test]
|
|
public void WaitFor_UnresolvableMember_TolerateMissing_RetriesUntilTimeout()
|
|
{
|
|
// Budget note: the FIRST poll pays the whole-appdomain scan for the unresolvable prefix
|
|
// (can be several hundred ms in a large editor domain); retries after it are cheap
|
|
// because a failed scan is negative-cached until a new assembly loads. The budget must
|
|
// comfortably exceed one cold scan or the loop legitimately exits after a single poll.
|
|
var result = (WaitResult)WaitForCommands.WaitFor(new WaitConditionInput
|
|
{
|
|
Member = "No.Such.Type.Member",
|
|
Op = "equals",
|
|
Value = true
|
|
}, timeoutS: 2, pollIntervalMs: 50, tolerateMissing: true);
|
|
|
|
Assert.AreEqual("timedOut", result.State, "tolerated resolution failures must run out the clock, not fail");
|
|
Assert.IsFalse(result.Met);
|
|
Assert.IsTrue(result.TimedOut);
|
|
Assert.IsNotNull(result.Error, "the last resolution error must be surfaced on timeout");
|
|
Assert.Greater(result.FramesObserved, 1, "the condition must have been retried");
|
|
}
|
|
|
|
[Test]
|
|
public void WaitFor_TolerateMissing_MalformedTargetGlobalId_FailsFast()
|
|
{
|
|
// A globalId that fails to even PARSE is a property of the input STRING itself -- no
|
|
// amount of retrying makes it parseable -- unlike a well-formed globalId/instanceId
|
|
// that simply doesn't resolve to a loaded object yet (covered by the next test).
|
|
var result = (WaitResult)WaitForCommands.WaitFor(new WaitConditionInput
|
|
{
|
|
Target = new ObjectRef { GlobalId = "not-a-real-global-object-id" },
|
|
Member = "name",
|
|
Op = "equals",
|
|
Value = "x"
|
|
}, timeoutS: 5, pollIntervalMs: 50, tolerateMissing: true);
|
|
|
|
Assert.AreEqual("failed", result.State,
|
|
$"a malformed target.globalId must fail fast even under tolerate_missing, got {result.State}: {result.Error}");
|
|
Assert.AreEqual(1, result.FramesObserved, "must fail on the first poll, never retried");
|
|
}
|
|
|
|
[Test]
|
|
public void WaitFor_TolerateMissing_TargetNotYetLoaded_RetriesUntilTimeout()
|
|
{
|
|
// A well-formed target that simply hasn't appeared yet (the spawn-wait use case
|
|
// `target` shares with `findType`) must retry, not fail fast like a malformed handle.
|
|
var result = (WaitResult)WaitForCommands.WaitFor(new WaitConditionInput
|
|
{
|
|
Target = new ObjectRef { InstanceId = ObjectId.FromRaw(9999999) },
|
|
Member = "name",
|
|
Op = "equals",
|
|
Value = "x"
|
|
}, timeoutS: 0.3, pollIntervalMs: 50, tolerateMissing: true);
|
|
|
|
Assert.AreEqual("timedOut", result.State,
|
|
$"a not-yet-loaded target must retry under tolerate_missing, got {result.State}: {result.Error}");
|
|
Assert.Greater(result.FramesObserved, 1, "the condition must have been retried, not failed on the first poll");
|
|
}
|
|
|
|
[Test]
|
|
public void WaitFor_Async_TolerateMissing_ResolvesWhenInstanceAppears()
|
|
{
|
|
// The spawn-wait use case: wait for an object that does not exist yet.
|
|
var submit = (WaitResult)WaitForCommands.WaitFor(new WaitConditionInput
|
|
{
|
|
FindType = typeof(ProbeBehaviourAsset).FullName,
|
|
Member = "Label",
|
|
Op = "equals",
|
|
Value = "spawned"
|
|
}, timeoutS: 30, pollIntervalMs: 16, tolerateMissing: true, async: true);
|
|
|
|
WaitRegistry.Tick(); // no instance yet -> tolerated, still pending
|
|
var pending = (WaitResult)WaitForCommands.WaitStatus(submit.WaitId);
|
|
Assert.AreEqual("pending", pending.State, "a missing instance must not fail a tolerant wait");
|
|
|
|
m_Probe = ScriptableObject.CreateInstance<ProbeBehaviourAsset>();
|
|
m_Probe.Label = "spawned";
|
|
|
|
Thread.Sleep(20); // let the poll interval elapse
|
|
WaitRegistry.Tick();
|
|
|
|
var status = (WaitResult)WaitForCommands.WaitStatus(submit.WaitId);
|
|
Assert.AreEqual("met", status.State, "the wait must resolve once the instance appears");
|
|
Assert.IsTrue(status.Met);
|
|
Assert.IsNull(status.Error, "a successful resolution must clear the tolerated error");
|
|
}
|
|
|
|
[Test]
|
|
public void WaitFor_TolerateMissing_FindTypeBoundInstanceLacksMember_RetriesInsteadOfFailingFast()
|
|
{
|
|
// Which concrete instance findType binds to is a deterministic-but-arbitrary tie-break
|
|
// among whichever live candidates exist right now (FindInstance's lowest-instance-id
|
|
// pick) -- not a fixed property of the wait's target the way a `target` handle or a
|
|
// static path are. If the member doesn't exist on the ONE instance that happens to be
|
|
// bound today, a different (already-existing or not-yet-spawned) instance of a
|
|
// different subtype might still have it -- so this must retry under tolerate_missing,
|
|
// not fail fast like every other TryReadMember failure.
|
|
m_Probe = ScriptableObject.CreateInstance<ProbeBehaviourAsset>();
|
|
|
|
var result = (WaitResult)WaitForCommands.WaitFor(new WaitConditionInput
|
|
{
|
|
FindType = typeof(ProbeBehaviourAsset).FullName,
|
|
Member = "NoSuchMember",
|
|
Op = "equals",
|
|
Value = true
|
|
}, timeoutS: 0.3, pollIntervalMs: 50, tolerateMissing: true);
|
|
|
|
Assert.AreEqual("timedOut", result.State,
|
|
$"a missing member on the bound findType instance must retry under tolerate_missing, got {result.State}: {result.Error}");
|
|
Assert.Greater(result.FramesObserved, 1, "the condition must have been retried, not failed on the first poll");
|
|
}
|
|
|
|
[Test]
|
|
public void WaitFor_TolerateMissing_FindTypeNestedMemberMissing_StillFailsFast()
|
|
{
|
|
// The leniency above is scoped to the FIRST segment off the bound instance (the
|
|
// ambiguous-candidate pick). A member missing one segment further in is a permanent
|
|
// property of whatever concrete type that first segment's value turns out to be --
|
|
// already resolved, and no more mutable than a target/static path's member shape.
|
|
m_Probe = ScriptableObject.CreateInstance<ProbeBehaviourAsset>();
|
|
m_Probe.Target = m_Probe; // give Target a real, non-null UnityEngine.Object value
|
|
|
|
var result = (WaitResult)WaitForCommands.WaitFor(new WaitConditionInput
|
|
{
|
|
FindType = typeof(ProbeBehaviourAsset).FullName,
|
|
Member = "Target.NoSuchMember",
|
|
Op = "equals",
|
|
Value = true
|
|
}, timeoutS: 5, pollIntervalMs: 50, tolerateMissing: true);
|
|
|
|
Assert.AreEqual("failed", result.State,
|
|
$"a missing member one segment past the bound instance must still fail fast, got {result.State}: {result.Error}");
|
|
Assert.AreEqual(1, result.FramesObserved, "must fail on the first poll, never retried");
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------------------
|
|
// Review round 5: permanent vs. transient resolution failures under tolerate_missing
|
|
// ----------------------------------------------------------------------------------------
|
|
|
|
private static IEnumerable<TestCaseData> PermanentResolutionFailureConditions()
|
|
{
|
|
// A static path with fewer than 2 segments is a property of the input STRING — no
|
|
// amount of retrying (no assembly load, no elapsed time) ever makes it resolvable.
|
|
// tolerate_missing's contract is "does not exist YET"; this can never exist.
|
|
yield return new TestCaseData(
|
|
new WaitConditionInput { Member = "OnlySegment", Op = "equals", Value = true })
|
|
.SetName("WaitFor_TolerateMissing_MalformedStaticPath_FailsFast");
|
|
// findType naming a non-UnityEngine.Object type is a permanent misconfiguration: it can
|
|
// never bind to a "live instance" regardless of how long the wait retries.
|
|
yield return new TestCaseData(
|
|
new WaitConditionInput { FindType = typeof(string).FullName, Member = "Length", Op = "equals", Value = 0 })
|
|
.SetName("WaitFor_TolerateMissing_FindTypeNotUnityObject_FailsFast");
|
|
// A member that is write-only on the resolved type is a property of that TYPE's shape,
|
|
// which cannot change without a domain reload (which the wait would not survive anyway).
|
|
yield return new TestCaseData(
|
|
new WaitConditionInput { Member = "Unity.Pipeline.Tests.Editor.WaitForProbe.WriteOnly", Op = "equals", Value = true })
|
|
.SetName("WaitFor_TolerateMissing_WriteOnlyMember_FailsFast");
|
|
}
|
|
|
|
[TestCaseSource(nameof(PermanentResolutionFailureConditions))]
|
|
public void WaitFor_TolerateMissing_PermanentResolutionFailure_FailsFast(WaitConditionInput condition)
|
|
{
|
|
var result = (WaitResult)WaitForCommands.WaitFor(condition, timeoutS: 5, pollIntervalMs: 50, tolerateMissing: true);
|
|
|
|
Assert.AreEqual("failed", result.State,
|
|
$"a permanent resolution failure must fail fast even under tolerate_missing, got {result.State}: {result.Error}");
|
|
Assert.AreEqual(1, result.FramesObserved, "must fail on the first poll, never retried");
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------------------
|
|
// Review round 5: op normalization parity between ValidateCondition and Evaluate
|
|
// ----------------------------------------------------------------------------------------
|
|
|
|
[Test]
|
|
public void Evaluate_EmptyStringOp_NormalizesToEquals()
|
|
{
|
|
// ValidateCondition treats op:"" as "equals" and lets the wait register; Evaluate must
|
|
// agree, or a condition that passed validation would fail every poll on "Unknown op ''".
|
|
Assert.IsTrue(WaitConditionEvaluator.Evaluate(
|
|
"", 1, 1, typeof(int), null, false, out var err));
|
|
Assert.IsNull(err);
|
|
}
|
|
|
|
[Test]
|
|
public void WaitFor_EmptyStringOp_BehavesAsEquals()
|
|
{
|
|
var result = (WaitResult)WaitForCommands.WaitFor(new WaitConditionInput
|
|
{
|
|
Member = "Unity.Pipeline.Tests.Editor.WaitForProbe.AlwaysTrue",
|
|
Op = "",
|
|
Value = JToken.FromObject(true)
|
|
}, timeoutS: 5, pollIntervalMs: 50);
|
|
|
|
Assert.AreEqual("met", result.State,
|
|
$"op:'' must behave as 'equals' end-to-end, got {result.State}: {result.Error}");
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------------------
|
|
// on_met follow-up (capture) — same call that first observes the condition true
|
|
// ----------------------------------------------------------------------------------------
|
|
|
|
[Test]
|
|
public void WaitFor_OnMetCapture_RunsWhenConditionHolds()
|
|
{
|
|
var onMet = new WaitOnMetInput { Capture = new WaitCaptureInput { View = "game", SavePath = "Screenshots/x.png" } };
|
|
|
|
var result = (WaitResult)WaitForCommands.WaitFor(
|
|
StaticCondition("AlwaysTrue", "equals", true), timeoutS: 5, pollIntervalMs: 50, onMet: onMet);
|
|
|
|
Assert.IsTrue(result.Met);
|
|
Assert.IsNotNull(result.Capture, "on_met.capture should populate the capture result");
|
|
Assert.AreEqual("Assets/__fake_wait_capture.png", result.Capture.SavedPath);
|
|
Assert.AreEqual(1, s_FakeCaptureCount, "capture must run exactly once, on the frame the condition first holds");
|
|
}
|
|
|
|
[Test]
|
|
public void WaitFor_NoCapture_WhenConditionNeverHolds()
|
|
{
|
|
var onMet = new WaitOnMetInput { Capture = new WaitCaptureInput { View = "game" } };
|
|
|
|
var result = (WaitResult)WaitForCommands.WaitFor(
|
|
StaticCondition("AlwaysFalse", "equals", true), timeoutS: 0.3, pollIntervalMs: 50, onMet: onMet);
|
|
|
|
Assert.IsTrue(result.TimedOut);
|
|
Assert.IsNull(result.Capture, "no capture should run when the condition never holds");
|
|
Assert.AreEqual(0, s_FakeCaptureCount);
|
|
}
|
|
|
|
[Test]
|
|
public void WaitFor_Async_OneFrameTrueCondition_IsCapturedInThatTick()
|
|
{
|
|
// AC: a condition true for exactly ONE registry tick (the probe consumes the pulse on
|
|
// read) must still be observed, captured, and reported as met.
|
|
var submit = (WaitResult)WaitForCommands.WaitFor(
|
|
StaticCondition("Pulse", "equals", true), timeoutS: 30, pollIntervalMs: 16, async: true,
|
|
onMet: new WaitOnMetInput { Capture = new WaitCaptureInput { View = "game" } });
|
|
|
|
WaitRegistry.Tick(); // pulse not armed -> observed false
|
|
Assert.AreEqual(0, s_FakeCaptureCount);
|
|
|
|
Thread.Sleep(20); // let the poll interval elapse
|
|
WaitForProbe.PulseArmed = true;
|
|
WaitRegistry.Tick(); // the ONE tick the condition is true
|
|
|
|
Assert.AreEqual(1, s_FakeCaptureCount, "on_met.capture must run during the single tick the condition held");
|
|
Assert.IsFalse(WaitForProbe.PulseArmed, "the probe must have consumed the pulse (one-frame-true)");
|
|
|
|
var status = (WaitResult)WaitForCommands.WaitStatus(submit.WaitId);
|
|
Assert.AreEqual("met", status.State, "the one-frame-true condition must be reported as met");
|
|
Assert.IsTrue(status.Met);
|
|
|
|
Thread.Sleep(20);
|
|
WaitRegistry.Tick(); // terminal: no further polls / captures
|
|
Assert.AreEqual(1, s_FakeCaptureCount, "a terminal wait must not poll or capture again");
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------------------
|
|
// return_history
|
|
// ----------------------------------------------------------------------------------------
|
|
|
|
[Test]
|
|
public void WaitFor_ReturnHistory_RecordsSamples()
|
|
{
|
|
var result = (WaitResult)WaitForCommands.WaitFor(
|
|
StaticCondition("AlwaysTrue", "equals", true), timeoutS: 5, pollIntervalMs: 50, returnHistory: true);
|
|
|
|
Assert.IsNotNull(result.History, "history should be populated when return_history is set");
|
|
Assert.GreaterOrEqual(result.History.Count, 1);
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------------------
|
|
// Async: wait_id / status / cancel / cap / interruption
|
|
// ----------------------------------------------------------------------------------------
|
|
|
|
[Test]
|
|
public void WaitFor_Async_ReturnsWaitIdThenReachesMet()
|
|
{
|
|
var submit = (WaitResult)WaitForCommands.WaitFor(
|
|
StaticCondition("AlwaysTrue", "equals", true), timeoutS: 30, pollIntervalMs: 16, async: true);
|
|
|
|
Assert.IsNotNull(submit.WaitId, "async submission should return a wait_id");
|
|
Assert.AreEqual("pending", submit.State);
|
|
|
|
WaitRegistry.Tick(); // one frame of evaluation
|
|
|
|
var status = (WaitResult)WaitForCommands.WaitStatus(submit.WaitId);
|
|
Assert.IsTrue(status.Met, "the async wait should reach 'met' after a tick");
|
|
Assert.AreEqual("met", status.State);
|
|
}
|
|
|
|
[Test]
|
|
public void WaitStatus_AfterTerminal_ElapsedMsIsFrozen()
|
|
{
|
|
var submit = (WaitResult)WaitForCommands.WaitFor(
|
|
StaticCondition("AlwaysTrue", "equals", true), timeoutS: 30, pollIntervalMs: 16, async: true);
|
|
|
|
WaitRegistry.Tick(); // -> met
|
|
|
|
var first = (WaitResult)WaitForCommands.WaitStatus(submit.WaitId);
|
|
Assert.AreEqual("met", first.State);
|
|
|
|
Thread.Sleep(60);
|
|
var second = (WaitResult)WaitForCommands.WaitStatus(submit.WaitId);
|
|
Assert.AreEqual(first.ElapsedMs, second.ElapsedMs,
|
|
"elapsedMs must freeze at the terminal transition, not keep growing while retained");
|
|
}
|
|
|
|
[Test]
|
|
public void WaitStatus_AfterObservedObjectDestroyed_SerializesAsNull()
|
|
{
|
|
// A terminal job's LastValue is cached at the poll that resolved it; the object it
|
|
// referenced can be destroyed afterward (e.g. scene unload) before a LATER wait_status
|
|
// call re-serializes it. Jsonify must re-check Unity's fake-null at serialization time,
|
|
// the same way ReadMember normalizes a destroyed leaf to null at READ time — a stale
|
|
// `!= null` check would otherwise call .ToString() on a destroyed reference instead of
|
|
// honoring the documented "a destroyed object observes as null" contract.
|
|
m_Probe = ScriptableObject.CreateInstance<ProbeBehaviourAsset>();
|
|
var target = new GameObject("WaitFor_Jsonify_Destroyed");
|
|
m_Probe.Target = target;
|
|
|
|
var submit = (WaitResult)WaitForCommands.WaitFor(new WaitConditionInput
|
|
{
|
|
Target = new ObjectRef { InstanceId = PipelineUtils.GetObjectId(m_Probe) },
|
|
Member = "Target",
|
|
Op = "notEquals",
|
|
Value = JValue.CreateNull()
|
|
}, timeoutS: 5, pollIntervalMs: 50, async: true);
|
|
|
|
WaitRegistry.Tick(); // observes Target != null -> met; caches the live GameObject
|
|
|
|
var metStatus = (WaitResult)WaitForCommands.WaitStatus(submit.WaitId);
|
|
Assert.AreEqual("met", metStatus.State, $"precondition: {metStatus.Error}");
|
|
|
|
UnityEngine.Object.DestroyImmediate(target); // destroyed AFTER the wait already resolved
|
|
|
|
WaitResult afterDestroy = null;
|
|
Assert.DoesNotThrow(() => afterDestroy = (WaitResult)WaitForCommands.WaitStatus(submit.WaitId),
|
|
"re-serializing a cached value whose object was destroyed since must not throw");
|
|
Assert.IsNull(afterDestroy.Value,
|
|
"a destroyed cached reference must serialize as null, matching read-time semantics");
|
|
}
|
|
|
|
[Test]
|
|
public void WaitCancel_Async_TransitionsToCanceled()
|
|
{
|
|
var submit = (WaitResult)WaitForCommands.WaitFor(
|
|
StaticCondition("AlwaysFalse", "equals", true), timeoutS: 60, pollIntervalMs: 16, async: true);
|
|
|
|
var canceled = (WaitResult)WaitForCommands.WaitCancel(submit.WaitId);
|
|
Assert.AreEqual("canceled", canceled.State);
|
|
|
|
var status = (WaitResult)WaitForCommands.WaitStatus(submit.WaitId);
|
|
Assert.AreEqual("canceled", status.State);
|
|
}
|
|
|
|
[Test]
|
|
public void WaitCancel_AlreadyResolved_ReportsRealStateNotFakeCanceled()
|
|
{
|
|
// Review round 5: wait_cancel arriving after the wait already reached a terminal state
|
|
// must report that real state, not force-report "canceled" — a caller polling wait_status
|
|
// right after would otherwise see a state that contradicts what wait_cancel just claimed.
|
|
var submit = (WaitResult)WaitForCommands.WaitFor(
|
|
StaticCondition("AlwaysTrue", "equals", true), timeoutS: 30, pollIntervalMs: 16, async: true);
|
|
|
|
WaitRegistry.Tick(); // -> met, before the cancel arrives
|
|
|
|
var cancelReply = (WaitResult)WaitForCommands.WaitCancel(submit.WaitId);
|
|
Assert.AreEqual("met", cancelReply.State,
|
|
"cancel has no effect on an already-terminal wait and must not fabricate 'canceled'");
|
|
Assert.IsTrue(cancelReply.Met);
|
|
}
|
|
|
|
[Test]
|
|
public void WaitStatus_UnknownId_ReturnsNotFound()
|
|
{
|
|
var status = (WaitResult)WaitForCommands.WaitStatus("does-not-exist");
|
|
Assert.AreEqual("not_found", status.State);
|
|
}
|
|
|
|
[Test]
|
|
public void PruneTerminal_RetainsMostRecentlyFinished_NotMostRecentlySubmitted()
|
|
{
|
|
// Regression: PruneTerminal used to order terminal jobs by StartedAt (submission time)
|
|
// instead of when they actually finished. A long-running wait submitted FIRST but
|
|
// resolved LAST -- exactly the "one long wait among many short ones" pattern the
|
|
// process-wide registry is meant to support -- was evicted the instant it became
|
|
// terminal, purely for having the oldest StartedAt, while short waits that had finished
|
|
// long before it (but were submitted later) survived.
|
|
var longWait = (WaitResult)WaitForCommands.WaitFor(
|
|
StaticCondition("AlwaysFalse", "equals", true), timeoutS: 300, pollIntervalMs: 100, async: true);
|
|
|
|
for (var i = 0; i < WaitRegistry.MaxRetainedTerminal; i++)
|
|
{
|
|
var shortWait = (WaitResult)WaitForCommands.WaitFor(
|
|
StaticCondition("AlwaysTrue", "equals", true), timeoutS: 5, pollIntervalMs: 50, async: true);
|
|
WaitRegistry.Tick(); // resolves immediately -> terminal, well before longWait
|
|
var shortStatus = (WaitResult)WaitForCommands.WaitStatus(shortWait.WaitId);
|
|
Assert.AreEqual("met", shortStatus.State, "precondition: each short wait must resolve before the long one");
|
|
}
|
|
|
|
// The long wait finishes LAST (the largest terminal timestamp of any job here) despite
|
|
// having the SMALLEST StartedAt in the registry.
|
|
var canceled = (WaitResult)WaitForCommands.WaitCancel(longWait.WaitId);
|
|
Assert.AreEqual("canceled", canceled.State);
|
|
|
|
// One more registration triggers PruneTerminal with MaxRetainedTerminal terminal jobs
|
|
// already retained (the short waits + the long one) -- exactly one must be evicted.
|
|
var trigger = (WaitResult)WaitForCommands.WaitFor(
|
|
StaticCondition("AlwaysFalse", "equals", true), timeoutS: 5, pollIntervalMs: 100, async: true);
|
|
try
|
|
{
|
|
var longStatus = (WaitResult)WaitForCommands.WaitStatus(longWait.WaitId);
|
|
Assert.AreEqual("canceled", longStatus.State,
|
|
"the long wait just finished and must survive pruning, not be evicted for having the oldest StartedAt");
|
|
}
|
|
finally
|
|
{
|
|
WaitForCommands.WaitCancel(trigger.WaitId);
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void WaitFor_Async_ConcurrencyCap_IsEnforced()
|
|
{
|
|
for (var i = 0; i < WaitRegistry.MaxConcurrentWaits; i++)
|
|
{
|
|
var submit = (WaitResult)WaitForCommands.WaitFor(
|
|
StaticCondition("AlwaysFalse", "equals", true), timeoutS: 300, pollIntervalMs: 100, async: true);
|
|
Assert.AreEqual("pending", submit.State);
|
|
}
|
|
|
|
var ex = Assert.Throws<InvalidOperationException>(() => WaitForCommands.WaitFor(
|
|
StaticCondition("AlwaysFalse", "equals", true), timeoutS: 300, pollIntervalMs: 100, async: true));
|
|
StringAssert.Contains("concurrent", ex.Message);
|
|
StringAssert.Contains("wait_cancel", ex.Message, "the cap violation must tell the caller how to free a slot");
|
|
}
|
|
|
|
[Test]
|
|
public void WaitFor_Async_Interrupted_TransitionsToInterrupted()
|
|
{
|
|
var submit = (WaitResult)WaitForCommands.WaitFor(
|
|
StaticCondition("AlwaysFalse", "equals", true), timeoutS: 60, pollIntervalMs: 16, async: true);
|
|
|
|
WaitRegistry.InterruptAllForTests(); // simulates a domain reload / play-mode exit
|
|
|
|
var status = (WaitResult)WaitForCommands.WaitStatus(submit.WaitId);
|
|
Assert.AreEqual("interrupted", status.State);
|
|
Assert.IsTrue(status.Interrupted ?? false);
|
|
}
|
|
|
|
[Test]
|
|
public void WaitFor_SurvivesEnteringPlayMode_InterruptedOnExit()
|
|
{
|
|
// Ticket scope: only EXITING play mode (and domain reloads via beforeAssemblyReload)
|
|
// interrupt a wait. Entering play mode must not (waits survive play entry when domain
|
|
// reload is disabled; with reload enabled, beforeAssemblyReload interrupts anyway).
|
|
var submit = (WaitResult)WaitForCommands.WaitFor(
|
|
StaticCondition("AlwaysFalse", "equals", true), timeoutS: 60, pollIntervalMs: 16, async: true);
|
|
|
|
WaitRegistry.SimulatePlayModeChangeForTests(PlayModeStateChange.ExitingEditMode);
|
|
WaitRegistry.SimulatePlayModeChangeForTests(PlayModeStateChange.EnteredPlayMode);
|
|
var afterEnter = (WaitResult)WaitForCommands.WaitStatus(submit.WaitId);
|
|
Assert.AreEqual("pending", afterEnter.State, "entering play mode must not interrupt an active wait");
|
|
|
|
WaitRegistry.SimulatePlayModeChangeForTests(PlayModeStateChange.ExitingPlayMode);
|
|
var afterExit = (WaitResult)WaitForCommands.WaitStatus(submit.WaitId);
|
|
Assert.AreEqual("interrupted", afterExit.State, "exiting play mode must interrupt an active wait");
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------------------
|
|
// ViaClient: full HTTP + off-main-thread marshaling
|
|
// ----------------------------------------------------------------------------------------
|
|
|
|
[Test]
|
|
public void WaitFor_ViaClient_SyncConditionAlreadyTrue_ReturnsMet()
|
|
{
|
|
using (var server = new PipelineTestServer())
|
|
{
|
|
var parameters = new
|
|
{
|
|
condition = new { member = "Unity.Pipeline.Tests.Editor.WaitForProbe.AlwaysTrue", op = "equals", value = true },
|
|
timeout_s = 5,
|
|
poll_interval_ms = 50
|
|
};
|
|
|
|
var response = server.Execute("wait_for", parameters, timeoutMs: 15000);
|
|
|
|
Assert.IsTrue(response.HasValidJson, $"expected JSON, got: {response.RawResponse}");
|
|
var met = response.JsonResponse["result"]?["met"]?.ToObject<bool>();
|
|
Assert.IsTrue(met.GetValueOrDefault(), $"wait_for should report met=true; body: {response.RawResponse}");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void WaitFor_ViaClient_AsyncWait_DoesNotBlockOtherCommands()
|
|
{
|
|
// AC: while an async wait is pending, other commands keep executing (a sync wait would
|
|
// hold the exec gate instead).
|
|
using (var server = new PipelineTestServer())
|
|
{
|
|
var submit = server.Execute("wait_for", new
|
|
{
|
|
condition = new { member = "Unity.Pipeline.Tests.Editor.WaitForProbe.AlwaysFalse", op = "equals", value = true },
|
|
timeout_s = 30,
|
|
poll_interval_ms = 100,
|
|
@async = true // '@' escapes the keyword; the serialized property name is "async"
|
|
}, timeoutMs: 15000);
|
|
|
|
Assert.IsTrue(submit.IsSuccess, $"async submission should succeed: {submit.Error}");
|
|
var waitId = submit.JsonResponse["result"]?["waitId"]?.ToString();
|
|
Assert.IsFalse(string.IsNullOrEmpty(waitId), $"async submission must return a waitId; body: {submit.RawResponse}");
|
|
|
|
var status = server.Execute("editor_status", null, timeoutMs: 15000);
|
|
Assert.IsTrue(status.IsSuccess,
|
|
$"editor_status must execute while the async wait is pending: {status.Error}");
|
|
|
|
var cancel = server.Execute("wait_cancel", new { wait_id = waitId }, timeoutMs: 15000);
|
|
Assert.IsTrue(cancel.IsSuccess, $"wait_cancel should succeed: {cancel.Error}");
|
|
Assert.AreEqual("canceled", cancel.JsonResponse["result"]?["state"]?.ToString());
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------------------
|
|
// Helpers
|
|
// ----------------------------------------------------------------------------------------
|
|
|
|
private static WaitConditionInput StaticCondition(string staticMember, string op, object value)
|
|
{
|
|
return new WaitConditionInput
|
|
{
|
|
Member = "Unity.Pipeline.Tests.Editor.WaitForProbe." + staticMember,
|
|
Op = op,
|
|
Value = value == null ? null : JToken.FromObject(value)
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>Static probe with members of each shape wait_for observes; used for static-path tests.</summary>
|
|
static class WaitForProbe
|
|
{
|
|
public static ProbeState State;
|
|
public static int Count;
|
|
public static float Ratio;
|
|
public static string Label = string.Empty;
|
|
public static bool Ready;
|
|
public static DateTime FlipAt = DateTime.MaxValue;
|
|
public static bool PulseArmed;
|
|
|
|
public static bool AlwaysTrue => true;
|
|
public static bool AlwaysFalse => false;
|
|
public static bool Flipped => DateTime.UtcNow >= FlipAt;
|
|
|
|
/// <summary>Enum member that flips Running -> Done at <see cref="FlipAt"/> (latency tests).</summary>
|
|
public static ProbeState TimedState => DateTime.UtcNow >= FlipAt ? ProbeState.Done : ProbeState.Running;
|
|
|
|
/// <summary>True exactly once per arm: reading it consumes the pulse (one-frame-true seam).</summary>
|
|
public static bool Pulse
|
|
{
|
|
get
|
|
{
|
|
if (!PulseArmed)
|
|
return false;
|
|
PulseArmed = false;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Write-only: must be rejected as unobservable.
|
|
public static bool WriteOnly { set { } }
|
|
|
|
public static void Reset()
|
|
{
|
|
State = ProbeState.Idle;
|
|
Count = 0;
|
|
Ratio = 0f;
|
|
Label = string.Empty;
|
|
Ready = false;
|
|
FlipAt = DateTime.MaxValue;
|
|
PulseArmed = false;
|
|
}
|
|
}
|
|
|
|
enum ProbeState { Idle, Running, Done }
|
|
|
|
/// <summary>UnityEngine.Object probe for findType / target instance-member resolution tests.</summary>
|
|
class ProbeBehaviourAsset : ScriptableObject
|
|
{
|
|
public float Ratio;
|
|
public string Label = string.Empty;
|
|
public ProbeState State;
|
|
|
|
/// <summary>Holds a (possibly destroyed) Unity object for fake-null traversal tests.</summary>
|
|
public UnityEngine.Object Target;
|
|
|
|
/// <summary>A member whose getter throws, for structured-failure tests.</summary>
|
|
public string Explodes => throw new InvalidOperationException("probe getter boom");
|
|
}
|
|
}
|