From 6d7111edc8130c4b12b987e991944d3bbec1db9f Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sun, 12 Jul 2026 19:03:28 +0900 Subject: [PATCH 1/3] feat: add watch expression registry core (#1730) --- .../Editor/WatchExpressionCompilerTests.cs | 63 ++++ .../WatchExpressionCompilerTests.cs.meta | 11 + .../Editor/WatchExpressionEvaluationTests.cs | 42 +++ .../WatchExpressionEvaluationTests.cs.meta | 11 + .../Editor/WatchExpressionRegistryTests.cs | 268 ++++++++++++++++++ .../WatchExpressionRegistryTests.cs.meta | 11 + .../Editor/FirstPartyTools/AssemblyInfo.cs | 3 + .../FirstPartyTools/AssemblyInfo.cs.meta | 11 + .../src/Editor/FirstPartyTools/Watch.meta | 8 + .../Watch/CompiledWatchExpressionEvaluator.cs | 35 +++ .../CompiledWatchExpressionEvaluator.cs.meta | 11 + .../Watch/IWatchEditorStateProvider.cs | 13 + .../Watch/IWatchEditorStateProvider.cs.meta | 11 + .../Watch/IWatchExpressionEvaluator.cs | 10 + .../Watch/IWatchExpressionEvaluator.cs.meta | 11 + .../Watch/UnityWatchEditorStateProvider.cs | 17 ++ .../UnityWatchEditorStateProvider.cs.meta | 11 + .../Watch/WatchCompilationResult.cs | 39 +++ .../Watch/WatchCompilationResult.cs.meta | 11 + .../Watch/WatchEvaluationResult.cs | 31 ++ .../Watch/WatchEvaluationResult.cs.meta | 11 + .../Watch/WatchExpressionCompiler.cs | 96 +++++++ .../Watch/WatchExpressionCompiler.cs.meta | 11 + .../Watch/WatchExpressionEntry.cs | 53 ++++ .../Watch/WatchExpressionEntry.cs.meta | 11 + .../Watch/WatchExpressionHistoryEntry.cs | 21 ++ .../Watch/WatchExpressionHistoryEntry.cs.meta | 11 + .../Watch/WatchExpressionRegistry.cs | 147 ++++++++++ .../Watch/WatchExpressionRegistry.cs.meta | 11 + .../Watch/WatchExpressionStepMonitor.cs | 45 +++ .../Watch/WatchExpressionStepMonitor.cs.meta | 11 + .../Watch/WatchRegistrationResult.cs | 27 ++ .../Watch/WatchRegistrationResult.cs.meta | 11 + 33 files changed, 1094 insertions(+) create mode 100644 Assets/Tests/Editor/WatchExpressionCompilerTests.cs create mode 100644 Assets/Tests/Editor/WatchExpressionCompilerTests.cs.meta create mode 100644 Assets/Tests/Editor/WatchExpressionEvaluationTests.cs create mode 100644 Assets/Tests/Editor/WatchExpressionEvaluationTests.cs.meta create mode 100644 Assets/Tests/Editor/WatchExpressionRegistryTests.cs create mode 100644 Assets/Tests/Editor/WatchExpressionRegistryTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/AssemblyInfo.cs create mode 100644 Packages/src/Editor/FirstPartyTools/AssemblyInfo.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Watch.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Watch/CompiledWatchExpressionEvaluator.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Watch/CompiledWatchExpressionEvaluator.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Watch/IWatchEditorStateProvider.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Watch/IWatchEditorStateProvider.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Watch/IWatchExpressionEvaluator.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Watch/IWatchExpressionEvaluator.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Watch/UnityWatchEditorStateProvider.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Watch/UnityWatchEditorStateProvider.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Watch/WatchCompilationResult.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Watch/WatchCompilationResult.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Watch/WatchEvaluationResult.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Watch/WatchEvaluationResult.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionCompiler.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionCompiler.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionEntry.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionEntry.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionHistoryEntry.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionHistoryEntry.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionRegistry.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionRegistry.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionStepMonitor.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionStepMonitor.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Watch/WatchRegistrationResult.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Watch/WatchRegistrationResult.cs.meta diff --git a/Assets/Tests/Editor/WatchExpressionCompilerTests.cs b/Assets/Tests/Editor/WatchExpressionCompilerTests.cs new file mode 100644 index 000000000..d796b7dd8 --- /dev/null +++ b/Assets/Tests/Editor/WatchExpressionCompilerTests.cs @@ -0,0 +1,63 @@ +using System.Collections; +using System.Threading; +using System.Threading.Tasks; + +using NUnit.Framework; + +using UnityEngine.TestTools; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies watch expressions compile through the shared dynamic-code compiler without executing user code. + /// + [TestFixture] + public sealed class WatchExpressionCompilerTests + { + private const int MaxCompilationWaitTicks = 600; + + /// + /// Verifies a valid expression reaches the compile-only path and produces an evaluator. + /// + [UnityTest] + public IEnumerator CompileAsync_ValidExpressionReturnsCompiledEvaluator() + { + WatchExpressionCompiler compiler = new(new DynamicCodeCompiler()); + Task compilationTask = compiler.CompileAsync("1 + 2", CancellationToken.None); + + int waitTicks = 0; + while (!compilationTask.IsCompleted && waitTicks < MaxCompilationWaitTicks) + { + waitTicks++; + yield return null; + } + + if (!compilationTask.IsCompleted) + { + Assert.Fail($"Watch expression compilation did not complete within {MaxCompilationWaitTicks} editor ticks."); + yield break; + } + + Assert.That(compilationTask.IsCompletedSuccessfully, Is.True); + Assert.That(compilationTask.Result.Success, Is.True); + Assert.That(compilationTask.Result.Evaluator, Is.Not.Null); + } + + /// + /// Verifies an empty expression is rejected before invoking the compiler. + /// + [Test] + public void CompileAsync_EmptyExpressionReturnsFailure() + { + WatchExpressionCompiler compiler = new(new DynamicCodeCompiler()); + + Task compilationTask = compiler.CompileAsync("", CancellationToken.None); + + Assert.That(compilationTask.IsCompletedSuccessfully, Is.True); + Assert.That(compilationTask.Result.Success, Is.False); + Assert.That(compilationTask.Result.ErrorMessage, Does.Contain("must not be empty")); + } + } +} diff --git a/Assets/Tests/Editor/WatchExpressionCompilerTests.cs.meta b/Assets/Tests/Editor/WatchExpressionCompilerTests.cs.meta new file mode 100644 index 000000000..10476ba82 --- /dev/null +++ b/Assets/Tests/Editor/WatchExpressionCompilerTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 34b102bf4e1b0405f86c5b52defe1971 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/WatchExpressionEvaluationTests.cs b/Assets/Tests/Editor/WatchExpressionEvaluationTests.cs new file mode 100644 index 000000000..9b4071486 --- /dev/null +++ b/Assets/Tests/Editor/WatchExpressionEvaluationTests.cs @@ -0,0 +1,42 @@ +using System; +using System.Reflection; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies user-code evaluation failures become explicit watch history results. + /// + [TestFixture] + public sealed class WatchExpressionEvaluationTests + { + /// + /// Verifies an exception from the generated evaluator is returned as a typed failure result. + /// + [Test] + public void Evaluate_WhenUserCodeThrows_ReturnsFailureResult() + { + MethodInfo method = typeof(ThrowingWatchExpression).GetMethod(nameof(ThrowingWatchExpression.Evaluate)); + CompiledWatchExpressionEvaluator evaluator = new( + new ThrowingWatchExpression(), + method); + + WatchEvaluationResult result = evaluator.Evaluate(); + + Assert.That(result.Success, Is.False); + Assert.That(result.ErrorTypeName, Is.EqualTo(typeof(InvalidOperationException).FullName)); + Assert.That(result.ErrorMessage, Is.EqualTo("watch failed")); + } + + private sealed class ThrowingWatchExpression + { + public object Evaluate() + { + throw new InvalidOperationException("watch failed"); + } + } + } +} diff --git a/Assets/Tests/Editor/WatchExpressionEvaluationTests.cs.meta b/Assets/Tests/Editor/WatchExpressionEvaluationTests.cs.meta new file mode 100644 index 000000000..f901509f2 --- /dev/null +++ b/Assets/Tests/Editor/WatchExpressionEvaluationTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c03d6a9e9430142e29b209d58774aa40 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/WatchExpressionRegistryTests.cs b/Assets/Tests/Editor/WatchExpressionRegistryTests.cs new file mode 100644 index 000000000..bfee488ac --- /dev/null +++ b/Assets/Tests/Editor/WatchExpressionRegistryTests.cs @@ -0,0 +1,268 @@ +using System.Collections.Generic; +using System.Linq; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies watch expression registration, ordered frame evaluation, bounded history, and re-entry protection. + /// + [TestFixture] + public sealed class WatchExpressionRegistryTests + { + /// + /// Verifies registration evaluates an immediate baseline frame. + /// + [Test] + public void Register_AddsImmediateBaselineToHistory() + { + FakeWatchEditorStateProvider stateProvider = new(10, true, true); + WatchExpressionRegistry registry = new(stateProvider); + + WatchRegistrationResult result = registry.Register( + "speed", + "speed", + new SequenceWatchExpressionEvaluator(3), + 20); + + Assert.That(result.Success, Is.True); + WatchExpressionHistoryEntry entry = registry.GetHistory("speed").Single(); + Assert.That(entry.FrameCount, Is.EqualTo(10)); + Assert.That(entry.Result.Value, Is.EqualTo(3)); + } + + /// + /// Verifies changed frames evaluate watches in registration order only once per frame. + /// + [Test] + public void EvaluateIfFrameChanged_EvaluatesInRegistrationOrderAndSkipsSameFrame() + { + FakeWatchEditorStateProvider stateProvider = new(10, true, true); + List evaluationOrder = new(); + WatchExpressionRegistry registry = new(stateProvider); + registry.Register("first", "first", new RecordingWatchExpressionEvaluator("first", evaluationOrder), 20); + registry.Register("second", "second", new RecordingWatchExpressionEvaluator("second", evaluationOrder), 20); + evaluationOrder.Clear(); + + stateProvider.FrameCount = 11; + bool evaluated = registry.EvaluateIfFrameChanged(); + bool evaluatedAgain = registry.EvaluateIfFrameChanged(); + + Assert.That(evaluated, Is.True); + Assert.That(evaluatedAgain, Is.False); + Assert.That(evaluationOrder, Is.EqualTo(new[] { "first", "second" })); + Assert.That(registry.GetHistory("first"), Has.Count.EqualTo(2)); + Assert.That(registry.GetHistory("second"), Has.Count.EqualTo(2)); + } + + /// + /// Verifies registering a watch after a frame advances does not skip existing watches on that frame. + /// + [Test] + public void Register_AfterFrameAdvance_DoesNotSkipExistingWatchEvaluation() + { + FakeWatchEditorStateProvider stateProvider = new(10, true, true); + WatchExpressionRegistry registry = new(stateProvider); + registry.Register("first", "first", new SequenceWatchExpressionEvaluator(1, 2), 20); + + stateProvider.FrameCount = 11; + registry.Register("second", "second", new SequenceWatchExpressionEvaluator(3), 20); + registry.EvaluateIfFrameChanged(); + + Assert.That(registry.GetHistory("first"), Has.Count.EqualTo(2)); + Assert.That(registry.GetHistory("second"), Has.Count.EqualTo(1)); + } + + /// + /// Verifies a frame change while not paused does not evaluate watches before a paused evaluation. + /// + [Test] + public void EvaluateIfFrameChanged_WhenNotPaused_DoesNotEvaluate() + { + FakeWatchEditorStateProvider stateProvider = new(10, true, false); + SequenceWatchExpressionEvaluator evaluator = new(1, 2); + WatchExpressionRegistry registry = new(stateProvider); + registry.Register("speed", "speed", evaluator, 20); + + stateProvider.FrameCount = 11; + bool evaluatedWhileNotPaused = registry.EvaluateIfFrameChanged(); + + Assert.That(evaluatedWhileNotPaused, Is.False); + Assert.That(registry.GetHistory("speed"), Has.Count.EqualTo(1)); + Assert.That(evaluator.EvaluationCount, Is.EqualTo(1)); + + stateProvider.IsPaused = true; + bool evaluatedAfterPause = registry.EvaluateIfFrameChanged(); + + Assert.That(evaluatedAfterPause, Is.True); + Assert.That(registry.GetHistory("speed"), Has.Count.EqualTo(2)); + Assert.That(evaluator.EvaluationCount, Is.EqualTo(2)); + } + + /// + /// Verifies each watch keeps only its configured history tail and dropped count. + /// + [Test] + public void History_WhenLimitIsExceeded_DropsOldestEntry() + { + FakeWatchEditorStateProvider stateProvider = new(10, true, true); + WatchExpressionRegistry registry = new(stateProvider); + registry.Register("speed", "speed", new SequenceWatchExpressionEvaluator(0, 1, 2), 2); + + stateProvider.FrameCount = 11; + registry.EvaluateIfFrameChanged(); + stateProvider.FrameCount = 12; + registry.EvaluateIfFrameChanged(); + + IReadOnlyList history = registry.GetHistory("speed"); + Assert.That(history.Select(entry => entry.FrameCount), Is.EqualTo(new[] { 11, 12 })); + Assert.That(registry.GetHistoryDroppedCount("speed"), Is.EqualTo(1)); + } + + /// + /// Verifies evaluator re-entry does not start a nested registry evaluation. + /// + [Test] + public void EvaluateIfFrameChanged_WhenEvaluatorReentersRegistry_DoesNotEvaluateNestedFrame() + { + FakeWatchEditorStateProvider stateProvider = new(10, true, true); + WatchExpressionRegistry registry = new(stateProvider); + ReenteringWatchExpressionEvaluator evaluator = new(() => registry.EvaluateIfFrameChanged()); + registry.Register("speed", "speed", evaluator, 20); + + stateProvider.FrameCount = 11; + bool evaluated = registry.EvaluateIfFrameChanged(); + + Assert.That(evaluated, Is.True); + Assert.That(evaluator.EvaluationCount, Is.EqualTo(2)); + Assert.That(registry.GetHistory("speed"), Has.Count.EqualTo(2)); + } + + /// + /// Verifies clearing all watches removes every registered expression. + /// + [Test] + public void ClearAll_RemovesAllRegisteredExpressions() + { + FakeWatchEditorStateProvider stateProvider = new(10, true, true); + WatchExpressionRegistry registry = new(stateProvider); + registry.Register("first", "first", new SequenceWatchExpressionEvaluator(1), 20); + registry.Register("second", "second", new SequenceWatchExpressionEvaluator(2), 20); + + int clearedCount = registry.ClearAll(); + + Assert.That(clearedCount, Is.EqualTo(2)); + Assert.That(registry.GetEntries(), Is.Empty); + } + + /// + /// Verifies clearing one watch leaves the other watch evaluating on later frames. + /// + [Test] + public void Clear_RemovesOnlySelectedExpression() + { + FakeWatchEditorStateProvider stateProvider = new(10, true, true); + SequenceWatchExpressionEvaluator firstEvaluator = new(1, 2); + SequenceWatchExpressionEvaluator secondEvaluator = new(3, 4); + WatchExpressionRegistry registry = new(stateProvider); + registry.Register("first", "first", firstEvaluator, 20); + registry.Register("second", "second", secondEvaluator, 20); + + bool cleared = registry.Clear("first"); + stateProvider.FrameCount = 11; + bool evaluated = registry.EvaluateIfFrameChanged(); + + Assert.That(cleared, Is.True); + Assert.That(evaluated, Is.True); + Assert.That(registry.GetHistory("first"), Is.Empty); + Assert.That(registry.GetHistory("second"), Has.Count.EqualTo(2)); + Assert.That(secondEvaluator.EvaluationCount, Is.EqualTo(2)); + } + + private sealed class FakeWatchEditorStateProvider : IWatchEditorStateProvider + { + public FakeWatchEditorStateProvider(int frameCount, bool isPlaying, bool isPaused) + { + FrameCount = frameCount; + IsPlaying = isPlaying; + IsPaused = isPaused; + } + + public int FrameCount { get; set; } + public bool IsPlaying { get; set; } + public bool IsPaused { get; set; } + public System.DateTime UtcNow => new(2026, 6, 3, 0, 0, 0, System.DateTimeKind.Utc); + } + + private sealed class SequenceWatchExpressionEvaluator : IWatchExpressionEvaluator + { + private readonly Queue _values; + private object _lastValue; + + public SequenceWatchExpressionEvaluator(params object[] values) + { + _values = new Queue(values); + _lastValue = values.Length > 0 ? values[values.Length - 1] : null; + } + + public int EvaluationCount { get; private set; } + + public WatchEvaluationResult Evaluate() + { + EvaluationCount++; + if (_values.Count > 0) + { + _lastValue = _values.Dequeue(); + } + + return WatchEvaluationResult.SuccessResult(_lastValue); + } + } + + private sealed class RecordingWatchExpressionEvaluator : IWatchExpressionEvaluator + { + private readonly string _name; + private readonly List _evaluationOrder; + + public RecordingWatchExpressionEvaluator(string name, List evaluationOrder) + { + _name = name; + _evaluationOrder = evaluationOrder; + } + + public WatchEvaluationResult Evaluate() + { + _evaluationOrder.Add(_name); + return WatchEvaluationResult.SuccessResult(_name); + } + } + + private sealed class ReenteringWatchExpressionEvaluator : IWatchExpressionEvaluator + { + private readonly System.Action _reenter; + private bool _hasReentered; + + public ReenteringWatchExpressionEvaluator(System.Action reenter) + { + _reenter = reenter; + } + + public int EvaluationCount { get; private set; } + + public WatchEvaluationResult Evaluate() + { + EvaluationCount++; + if (!_hasReentered) + { + _hasReentered = true; + _reenter(); + } + + return WatchEvaluationResult.SuccessResult(EvaluationCount); + } + } + } +} diff --git a/Assets/Tests/Editor/WatchExpressionRegistryTests.cs.meta b/Assets/Tests/Editor/WatchExpressionRegistryTests.cs.meta new file mode 100644 index 000000000..5f1dfc020 --- /dev/null +++ b/Assets/Tests/Editor/WatchExpressionRegistryTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 03a8a4ba5c86747bd843d06da897b4f8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/AssemblyInfo.cs b/Packages/src/Editor/FirstPartyTools/AssemblyInfo.cs new file mode 100644 index 000000000..dfa2c7f96 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("UnityCLILoop.Tests.Editor")] diff --git a/Packages/src/Editor/FirstPartyTools/AssemblyInfo.cs.meta b/Packages/src/Editor/FirstPartyTools/AssemblyInfo.cs.meta new file mode 100644 index 000000000..3a95c67fd --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/AssemblyInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 677d4e62a27ed41d38b8c594190b855c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Watch.meta b/Packages/src/Editor/FirstPartyTools/Watch.meta new file mode 100644 index 000000000..585aa2b70 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Watch.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 176c4bcdaf3fb4a1b848edc027655003 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Watch/CompiledWatchExpressionEvaluator.cs b/Packages/src/Editor/FirstPartyTools/Watch/CompiledWatchExpressionEvaluator.cs new file mode 100644 index 000000000..c07a8395f --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Watch/CompiledWatchExpressionEvaluator.cs @@ -0,0 +1,35 @@ +using System; +using System.Reflection; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Invokes a compiled watch expression and converts user-code failures into history data. + /// + internal sealed class CompiledWatchExpressionEvaluator : IWatchExpressionEvaluator + { + private readonly Func _evaluate; + + public CompiledWatchExpressionEvaluator(object instance, MethodInfo evaluateMethod) + { + _evaluate = (Func)evaluateMethod.CreateDelegate(typeof(Func), instance); + } + + public WatchEvaluationResult Evaluate() + { + // Why: only the generated user-code delegate invocation may throw during normal watch evaluation; + // converting that exception keeps the Editor update loop alive and preserves the error in history. + try + { + object value = _evaluate(); + return WatchEvaluationResult.SuccessResult(value); + } + catch (Exception exception) + { + return WatchEvaluationResult.FailureResult( + exception.GetType().FullName ?? exception.GetType().Name, + exception.Message); + } + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Watch/CompiledWatchExpressionEvaluator.cs.meta b/Packages/src/Editor/FirstPartyTools/Watch/CompiledWatchExpressionEvaluator.cs.meta new file mode 100644 index 000000000..9c2ddb89a --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Watch/CompiledWatchExpressionEvaluator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 557de45ec69184c3d9064a487830da48 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Watch/IWatchEditorStateProvider.cs b/Packages/src/Editor/FirstPartyTools/Watch/IWatchEditorStateProvider.cs new file mode 100644 index 000000000..095d3ca5a --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Watch/IWatchEditorStateProvider.cs @@ -0,0 +1,13 @@ +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Supplies the Editor state required to detect a completed Play Mode Step. + /// + public interface IWatchEditorStateProvider + { + bool IsPlaying { get; } + bool IsPaused { get; } + int FrameCount { get; } + System.DateTime UtcNow { get; } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Watch/IWatchEditorStateProvider.cs.meta b/Packages/src/Editor/FirstPartyTools/Watch/IWatchEditorStateProvider.cs.meta new file mode 100644 index 000000000..9ebf23992 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Watch/IWatchEditorStateProvider.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 118b4ce3ffb6e43d3b510568ec91e54e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Watch/IWatchExpressionEvaluator.cs b/Packages/src/Editor/FirstPartyTools/Watch/IWatchExpressionEvaluator.cs new file mode 100644 index 000000000..840b855d2 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Watch/IWatchExpressionEvaluator.cs @@ -0,0 +1,10 @@ +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Evaluates one already-compiled watch expression at the current Editor state. + /// + public interface IWatchExpressionEvaluator + { + WatchEvaluationResult Evaluate(); + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Watch/IWatchExpressionEvaluator.cs.meta b/Packages/src/Editor/FirstPartyTools/Watch/IWatchExpressionEvaluator.cs.meta new file mode 100644 index 000000000..5aac8dc1a --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Watch/IWatchExpressionEvaluator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ef26be2705c3946d2a12747de1150c3c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Watch/UnityWatchEditorStateProvider.cs b/Packages/src/Editor/FirstPartyTools/Watch/UnityWatchEditorStateProvider.cs new file mode 100644 index 000000000..a31f1a107 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Watch/UnityWatchEditorStateProvider.cs @@ -0,0 +1,17 @@ +using System; +using UnityEditor; +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Reads the Unity Editor state used by the watch step monitor. + /// + public sealed class UnityWatchEditorStateProvider : IWatchEditorStateProvider + { + public bool IsPlaying => EditorApplication.isPlaying; + public bool IsPaused => EditorApplication.isPaused; + public int FrameCount => Time.frameCount; + public DateTime UtcNow => DateTime.UtcNow; + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Watch/UnityWatchEditorStateProvider.cs.meta b/Packages/src/Editor/FirstPartyTools/Watch/UnityWatchEditorStateProvider.cs.meta new file mode 100644 index 000000000..15f143bdb --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Watch/UnityWatchEditorStateProvider.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bdd0b6325165b4de1a87ff6dfdb6404a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Watch/WatchCompilationResult.cs b/Packages/src/Editor/FirstPartyTools/Watch/WatchCompilationResult.cs new file mode 100644 index 000000000..248f0db61 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Watch/WatchCompilationResult.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Reports compilation success or diagnostics for one watch expression. + /// + public sealed class WatchCompilationResult + { + private WatchCompilationResult( + bool success, + IWatchExpressionEvaluator evaluator, + string errorMessage, + IReadOnlyList compilationErrors) + { + Success = success; + Evaluator = evaluator; + ErrorMessage = errorMessage; + CompilationErrors = compilationErrors; + } + + public bool Success { get; } + public IWatchExpressionEvaluator Evaluator { get; } + public string ErrorMessage { get; } + public IReadOnlyList CompilationErrors { get; } + + public static WatchCompilationResult SuccessResult(IWatchExpressionEvaluator evaluator) + { + return new WatchCompilationResult(true, evaluator, string.Empty, new List()); + } + + public static WatchCompilationResult FailureResult( + string errorMessage, + IReadOnlyList compilationErrors) + { + return new WatchCompilationResult(false, null, errorMessage, compilationErrors); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Watch/WatchCompilationResult.cs.meta b/Packages/src/Editor/FirstPartyTools/Watch/WatchCompilationResult.cs.meta new file mode 100644 index 000000000..c8dd137f1 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Watch/WatchCompilationResult.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7a80a15c5934d4badaa39fbfb15725d4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Watch/WatchEvaluationResult.cs b/Packages/src/Editor/FirstPartyTools/Watch/WatchEvaluationResult.cs new file mode 100644 index 000000000..66422af34 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Watch/WatchEvaluationResult.cs @@ -0,0 +1,31 @@ +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Carries either a watch expression value or the user-code error that produced it. + /// + public sealed class WatchEvaluationResult + { + private WatchEvaluationResult(bool success, object value, string errorTypeName, string errorMessage) + { + Success = success; + Value = value; + ErrorTypeName = errorTypeName; + ErrorMessage = errorMessage; + } + + public bool Success { get; } + public object Value { get; } + public string ErrorTypeName { get; } + public string ErrorMessage { get; } + + public static WatchEvaluationResult SuccessResult(object value) + { + return new WatchEvaluationResult(true, value, string.Empty, string.Empty); + } + + public static WatchEvaluationResult FailureResult(string errorTypeName, string errorMessage) + { + return new WatchEvaluationResult(false, null, errorTypeName, errorMessage); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Watch/WatchEvaluationResult.cs.meta b/Packages/src/Editor/FirstPartyTools/Watch/WatchEvaluationResult.cs.meta new file mode 100644 index 000000000..c50474db2 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Watch/WatchEvaluationResult.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 15618384665094e95814d6145ddfa137 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionCompiler.cs b/Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionCompiler.cs new file mode 100644 index 000000000..41555926e --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionCompiler.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Wraps one user expression in a compiled evaluator using the execute-dynamic-code compiler. + /// + public sealed class WatchExpressionCompiler + { + private const string WatchNamespace = "io.github.hatayama.UnityCliLoop.DynamicWatch"; + private const string WatchClassName = "WatchExpressionEntryPoint"; + private const string EvaluateMethodName = "Evaluate"; + + private readonly IDynamicCompilationService _compilationService; + + public WatchExpressionCompiler(IDynamicCompilationService compilationService) + { + _compilationService = compilationService ?? throw new ArgumentNullException(nameof(compilationService)); + } + + public async Task CompileAsync(string expression, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(expression)) + { + return WatchCompilationResult.FailureResult( + "Watch expression must not be empty.", + new List()); + } + + CompilationResult compilationResult = await _compilationService.CompileAsync( + new CompilationRequest + { + Code = BuildSource(expression), + ClassName = WatchClassName, + Namespace = WatchNamespace + }, + ct).ConfigureAwait(false); + + if (!compilationResult.Success) + { + return WatchCompilationResult.FailureResult( + "Watch expression compilation failed.", + compilationResult.Errors); + } + + Type evaluatorType = compilationResult.CompiledAssembly.GetType( + $"{WatchNamespace}.{WatchClassName}", + false); + if (evaluatorType == null) + { + return WatchCompilationResult.FailureResult( + "Compiled watch expression evaluator type was not found.", + new List()); + } + + MethodInfo evaluateMethod = evaluatorType.GetMethod( + EvaluateMethodName, + BindingFlags.Public | BindingFlags.Instance, + null, + Type.EmptyTypes, + null); + if (evaluateMethod == null) + { + return WatchCompilationResult.FailureResult( + "Compiled watch expression evaluator method was not found.", + new List()); + } + + object evaluatorInstance = Activator.CreateInstance(evaluatorType); + IWatchExpressionEvaluator evaluator = new CompiledWatchExpressionEvaluator( + evaluatorInstance, + evaluateMethod); + return WatchCompilationResult.SuccessResult(evaluator); + } + + internal static string BuildSource(string expression) + { + return $"using System;\n" + + "using io.github.hatayama.UnityCliLoop.Runtime;\n" + + $"namespace {WatchNamespace}\n" + + "{\n" + + $" public sealed class {WatchClassName}\n" + + " {\n" + + " public object Evaluate()\n" + + " {\n" + + $" return (object)({expression});\n" + + " }\n" + + " }\n" + + "}\n"; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionCompiler.cs.meta b/Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionCompiler.cs.meta new file mode 100644 index 000000000..6e8ffd2bf --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionCompiler.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fced3cdc0c2dc4e8c92c38028e858cb6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionEntry.cs b/Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionEntry.cs new file mode 100644 index 000000000..c2564883b --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionEntry.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Owns one registered watch expression and its bounded evaluation history. + /// + public sealed class WatchExpressionEntry + { + private readonly Queue _history = new(); + + internal WatchExpressionEntry( + string id, + string expression, + IWatchExpressionEvaluator evaluator, + int maxHistory) + { + Id = id; + Expression = expression; + Evaluator = evaluator; + MaxHistory = maxHistory; + } + + public string Id { get; } + public string Expression { get; } + public int MaxHistory { get; } + public int HistoryDroppedCount { get; private set; } + internal int LastEvaluatedFrameCount { get; private set; } = int.MinValue; + internal IWatchExpressionEvaluator Evaluator { get; } + + internal void MarkEvaluated(int frameCount) + { + LastEvaluatedFrameCount = frameCount; + } + + internal void Append(int frameCount, DateTime evaluatedAtUtc, WatchEvaluationResult result) + { + if (_history.Count == MaxHistory) + { + _history.Dequeue(); + HistoryDroppedCount++; + } + + _history.Enqueue(new WatchExpressionHistoryEntry(frameCount, evaluatedAtUtc, result)); + } + + internal IReadOnlyList CreateHistorySnapshot() + { + return new List(_history); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionEntry.cs.meta b/Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionEntry.cs.meta new file mode 100644 index 000000000..851f06259 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionEntry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c5ef57c494a6247148bcb73b75a925ca +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionHistoryEntry.cs b/Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionHistoryEntry.cs new file mode 100644 index 000000000..2bb364f6f --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionHistoryEntry.cs @@ -0,0 +1,21 @@ +using System; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Represents one frame-indexed watch evaluation retained by the registry. + /// + public sealed class WatchExpressionHistoryEntry + { + public WatchExpressionHistoryEntry(int frameCount, DateTime evaluatedAtUtc, WatchEvaluationResult result) + { + FrameCount = frameCount; + EvaluatedAtUtc = evaluatedAtUtc; + Result = result; + } + + public int FrameCount { get; } + public DateTime EvaluatedAtUtc { get; } + public WatchEvaluationResult Result { get; } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionHistoryEntry.cs.meta b/Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionHistoryEntry.cs.meta new file mode 100644 index 000000000..14ccfc975 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionHistoryEntry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: db2897cbb0c78499ea44ef8f0bb0e539 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionRegistry.cs b/Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionRegistry.cs new file mode 100644 index 000000000..0d5ca2416 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionRegistry.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Manages ordered in-memory watch expressions and evaluates them once per paused frame. + /// + public sealed class WatchExpressionRegistry + { + public const int DefaultMaxHistory = 20; + public const int MaxHistoryLimit = 100; + + private readonly IWatchEditorStateProvider _stateProvider; + private readonly Dictionary _entriesById = new(StringComparer.Ordinal); + private readonly List _entries = new(); + private bool _isEvaluating; + + public WatchExpressionRegistry(IWatchEditorStateProvider stateProvider) + { + _stateProvider = stateProvider ?? throw new ArgumentNullException(nameof(stateProvider)); + } + + public WatchRegistrationResult Register( + string id, + string expression, + IWatchExpressionEvaluator evaluator, + int maxHistory) + { + Debug.Assert(!string.IsNullOrWhiteSpace(id), "id must not be null or empty"); + Debug.Assert(!string.IsNullOrWhiteSpace(expression), "expression must not be null or empty"); + Debug.Assert(evaluator != null, "evaluator must not be null"); + + if (_entriesById.ContainsKey(id)) + { + return WatchRegistrationResult.FailureResult($"Watch expression '{id}' is already registered."); + } + + if (maxHistory <= 0 || maxHistory > MaxHistoryLimit) + { + return WatchRegistrationResult.FailureResult( + $"maxHistory must be between 1 and {MaxHistoryLimit}."); + } + + WatchExpressionEntry entry = new(id, expression, evaluator, maxHistory); + _entriesById.Add(id, entry); + _entries.Add(entry); + int baselineFrameCount = _stateProvider.FrameCount; + entry.MarkEvaluated(baselineFrameCount); + _isEvaluating = true; + try + { + EvaluateEntry(entry, baselineFrameCount); + } + finally + { + _isEvaluating = false; + } + return WatchRegistrationResult.SuccessResult(); + } + + public bool Clear(string id) + { + if (!_entriesById.Remove(id, out WatchExpressionEntry entry)) + { + return false; + } + + _entries.Remove(entry); + return true; + } + + public int ClearAll() + { + int clearedCount = _entries.Count; + _entriesById.Clear(); + _entries.Clear(); + return clearedCount; + } + + public bool EvaluateIfFrameChanged() + { + if (!_stateProvider.IsPlaying || !_stateProvider.IsPaused) + { + return false; + } + + int frameCount = _stateProvider.FrameCount; + if (_isEvaluating) + { + return false; + } + + _isEvaluating = true; + bool evaluatedAny = false; + try + { + foreach (WatchExpressionEntry entry in _entries) + { + if (entry.LastEvaluatedFrameCount == frameCount) + { + continue; + } + + entry.MarkEvaluated(frameCount); + EvaluateEntry(entry, frameCount); + evaluatedAny = true; + } + } + finally + { + _isEvaluating = false; + } + + return evaluatedAny; + } + + public IReadOnlyList GetEntries() + { + return new List(_entries); + } + + public IReadOnlyList GetHistory(string id) + { + if (!_entriesById.TryGetValue(id, out WatchExpressionEntry entry)) + { + return Array.Empty(); + } + + return entry.CreateHistorySnapshot(); + } + + public int GetHistoryDroppedCount(string id) + { + return _entriesById.TryGetValue(id, out WatchExpressionEntry entry) + ? entry.HistoryDroppedCount + : 0; + } + + private void EvaluateEntry(WatchExpressionEntry entry, int frameCount) + { + WatchEvaluationResult result = entry.Evaluator.Evaluate(); + entry.Append(frameCount, _stateProvider.UtcNow, result); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionRegistry.cs.meta b/Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionRegistry.cs.meta new file mode 100644 index 000000000..be2f167e2 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionRegistry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 988618605f260437ab4bc3d39caf2d47 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionStepMonitor.cs b/Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionStepMonitor.cs new file mode 100644 index 000000000..8789a85ee --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionStepMonitor.cs @@ -0,0 +1,45 @@ +using UnityEditor; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Bridges Unity Editor update ticks to the watch registry's frame-change evaluator. + /// + public sealed class WatchExpressionStepMonitor + { + private readonly WatchExpressionRegistry _registry; + private bool _isStarted; + + public WatchExpressionStepMonitor(WatchExpressionRegistry registry) + { + _registry = registry; + } + + public void Start() + { + if (_isStarted) + { + return; + } + + EditorApplication.update += OnEditorUpdate; + _isStarted = true; + } + + public void Stop() + { + if (!_isStarted) + { + return; + } + + EditorApplication.update -= OnEditorUpdate; + _isStarted = false; + } + + private void OnEditorUpdate() + { + _registry.EvaluateIfFrameChanged(); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionStepMonitor.cs.meta b/Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionStepMonitor.cs.meta new file mode 100644 index 000000000..3bebf41bf --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Watch/WatchExpressionStepMonitor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 34f559671c8004bdaa658e0ef8f86947 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Watch/WatchRegistrationResult.cs b/Packages/src/Editor/FirstPartyTools/Watch/WatchRegistrationResult.cs new file mode 100644 index 000000000..49fb72506 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Watch/WatchRegistrationResult.cs @@ -0,0 +1,27 @@ +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Reports whether a watch expression was accepted by the in-memory registry. + /// + public sealed class WatchRegistrationResult + { + private WatchRegistrationResult(bool success, string errorMessage) + { + Success = success; + ErrorMessage = errorMessage; + } + + public bool Success { get; } + public string ErrorMessage { get; } + + public static WatchRegistrationResult SuccessResult() + { + return new WatchRegistrationResult(true, string.Empty); + } + + public static WatchRegistrationResult FailureResult(string errorMessage) + { + return new WatchRegistrationResult(false, errorMessage); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Watch/WatchRegistrationResult.cs.meta b/Packages/src/Editor/FirstPartyTools/Watch/WatchRegistrationResult.cs.meta new file mode 100644 index 000000000..e96872eed --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Watch/WatchRegistrationResult.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f50fed9662d3c4619804a02240e70aee +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From ec199761840120d34466686ee86d4b0e235fe06d Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sun, 12 Jul 2026 21:22:38 +0900 Subject: [PATCH 2/3] feat: expose watch expression tools (#1731) Co-authored-by: Cursor --- .agents/skills/uloop-pause-point/SKILL.md | 15 + .claude/skills/uloop-pause-point/SKILL.md | 15 + .../Editor/UnityCLILoop.Tests.Editor.asmdef | 3 +- .../Editor/WatchResponseContractTests.cs | 118 +++++++ .../Editor/WatchResponseContractTests.cs.meta | 11 + Assets/Tests/Editor/WatchToolTests.cs | 71 ++++ Assets/Tests/Editor/WatchToolTests.cs.meta | 11 + .../CliOnlyTools~/PausePoint/Skill/SKILL.md | 15 + .../ExecuteDynamicCode/AssemblyInfo.cs | 1 + ...UnityCLILoop.FirstPartyTools.Editor.asmdef | 3 +- .../{ => Watch}/AssemblyInfo.cs | 0 .../{ => Watch}/AssemblyInfo.cs.meta | 2 +- ...LILoop.FirstPartyTools.Watch.Editor.asmdef | 19 + ...p.FirstPartyTools.Watch.Editor.asmdef.meta | 7 + .../FirstPartyTools/Watch/WatchTools.cs | 329 ++++++++++++++++++ .../FirstPartyTools/Watch/WatchTools.cs.meta | 11 + .../ToolContracts/UnityCliLoopConstants.cs | 3 + cli/common/tools/default-tools.json | 52 +++ .../internal/dispatcher/help_test.go | 18 + .../watch_response_contract_test.go | 35 ++ .../internal/projectrunner/watch_types.go | 37 ++ tests/contracts/watch_response_contract.json | 28 ++ 22 files changed, 801 insertions(+), 3 deletions(-) create mode 100644 Assets/Tests/Editor/WatchResponseContractTests.cs create mode 100644 Assets/Tests/Editor/WatchResponseContractTests.cs.meta create mode 100644 Assets/Tests/Editor/WatchToolTests.cs create mode 100644 Assets/Tests/Editor/WatchToolTests.cs.meta rename Packages/src/Editor/FirstPartyTools/{ => Watch}/AssemblyInfo.cs (100%) rename Packages/src/Editor/FirstPartyTools/{ => Watch}/AssemblyInfo.cs.meta (83%) create mode 100644 Packages/src/Editor/FirstPartyTools/Watch/UnityCLILoop.FirstPartyTools.Watch.Editor.asmdef create mode 100644 Packages/src/Editor/FirstPartyTools/Watch/UnityCLILoop.FirstPartyTools.Watch.Editor.asmdef.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Watch/WatchTools.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Watch/WatchTools.cs.meta create mode 100644 cli/project-runner/internal/projectrunner/watch_response_contract_test.go create mode 100644 cli/project-runner/internal/projectrunner/watch_types.go create mode 100644 tests/contracts/watch_response_contract.json diff --git a/.agents/skills/uloop-pause-point/SKILL.md b/.agents/skills/uloop-pause-point/SKILL.md index 71bd2aa23..40442008b 100644 --- a/.agents/skills/uloop-pause-point/SKILL.md +++ b/.agents/skills/uloop-pause-point/SKILL.md @@ -75,6 +75,21 @@ While Unity is paused on a hit, `execute-dynamic-code` can read live captured re The holder clears when Unity resumes (not when you `Step` while still paused), when the matching pause point is cleared, when a new hit replaces the snapshot, or when PlayMode exits. After resume, `TryGetCapturedValue` returns `Found=false`. +## Watch Expressions + +Use watch expressions when the value should be evaluated automatically after each paused Play Mode Step: + +```bash +uloop enable-watch --id "speed" --expression "UloopPausePoint.TryGetCapturedValue(\"speed\").Value" --max-history 20 +uloop get-watch-values --id "speed" +``` + +`enable-watch` compiles the C# expression once, evaluates it immediately for a baseline, and then evaluates it once per changed `Time.frameCount` while Unity is both playing and paused. Multiple watches run in registration order. `enable-watch` rejects a duplicate id instead of overwriting; clear with `clear-watch --id ` before re-registering a changed expression. `clear-watch --id ` removes one watch; `clear-watch --all` removes all watches. `get-watch-values` without `--id` returns every registered watch. + +The expression may use `UloopPausePoint.TryGetCapturedValue("name")` to inspect the latest raw pause-point capture while paused. Each history entry includes the frame and either a stringified value or an explicit error type and message. A throwing expression is recorded as an error and does not stop the Editor update loop. `--max-history` accepts 1 through 100 and drops the oldest entries after the limit. + +Watch expressions are in-memory Editor state. A domain reload clears them, so re-register them after `uloop compile`, script recompilation, or an Editor restart. For reliable per-Step changes, keep the expression attached to a continuous pause point on an `Update` or `FixedUpdate` line and use `control-play-mode --action Step`. + ## Marker Types - `uloop enable-pause-point --file --line` patches the already-compiled method at a source line. No code edit or recompile is required. diff --git a/.claude/skills/uloop-pause-point/SKILL.md b/.claude/skills/uloop-pause-point/SKILL.md index 71bd2aa23..40442008b 100644 --- a/.claude/skills/uloop-pause-point/SKILL.md +++ b/.claude/skills/uloop-pause-point/SKILL.md @@ -75,6 +75,21 @@ While Unity is paused on a hit, `execute-dynamic-code` can read live captured re The holder clears when Unity resumes (not when you `Step` while still paused), when the matching pause point is cleared, when a new hit replaces the snapshot, or when PlayMode exits. After resume, `TryGetCapturedValue` returns `Found=false`. +## Watch Expressions + +Use watch expressions when the value should be evaluated automatically after each paused Play Mode Step: + +```bash +uloop enable-watch --id "speed" --expression "UloopPausePoint.TryGetCapturedValue(\"speed\").Value" --max-history 20 +uloop get-watch-values --id "speed" +``` + +`enable-watch` compiles the C# expression once, evaluates it immediately for a baseline, and then evaluates it once per changed `Time.frameCount` while Unity is both playing and paused. Multiple watches run in registration order. `enable-watch` rejects a duplicate id instead of overwriting; clear with `clear-watch --id ` before re-registering a changed expression. `clear-watch --id ` removes one watch; `clear-watch --all` removes all watches. `get-watch-values` without `--id` returns every registered watch. + +The expression may use `UloopPausePoint.TryGetCapturedValue("name")` to inspect the latest raw pause-point capture while paused. Each history entry includes the frame and either a stringified value or an explicit error type and message. A throwing expression is recorded as an error and does not stop the Editor update loop. `--max-history` accepts 1 through 100 and drops the oldest entries after the limit. + +Watch expressions are in-memory Editor state. A domain reload clears them, so re-register them after `uloop compile`, script recompilation, or an Editor restart. For reliable per-Step changes, keep the expression attached to a continuous pause point on an `Update` or `FixedUpdate` line and use `control-play-mode --action Step`. + ## Marker Types - `uloop enable-pause-point --file --line` patches the already-compiled method at a source line. No code edit or recompile is required. diff --git a/Assets/Tests/Editor/UnityCLILoop.Tests.Editor.asmdef b/Assets/Tests/Editor/UnityCLILoop.Tests.Editor.asmdef index 6a4a4cff2..22786365d 100644 --- a/Assets/Tests/Editor/UnityCLILoop.Tests.Editor.asmdef +++ b/Assets/Tests/Editor/UnityCLILoop.Tests.Editor.asmdef @@ -39,7 +39,8 @@ "GUID:332048ead1ebf4658b9961338e5d68c3", "GUID:33fef506e6744f2982e8c13b1196f696", "GUID:da253b56f9d34380980fbe7bafe64666", - "GUID:96b8d4624fb74ea2849fed17e7ad69d3" + "GUID:96b8d4624fb74ea2849fed17e7ad69d3", + "GUID:e3fd8b49afd6f4e3cae09011028922b4" ], "includePlatforms": [ "Editor" diff --git a/Assets/Tests/Editor/WatchResponseContractTests.cs b/Assets/Tests/Editor/WatchResponseContractTests.cs new file mode 100644 index 000000000..20c5c6ab2 --- /dev/null +++ b/Assets/Tests/Editor/WatchResponseContractTests.cs @@ -0,0 +1,118 @@ +using System.Collections.Generic; +using System.IO; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.Infrastructure; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies the Unity watch tool response stays aligned with the shared Go CLI contract. + /// + public sealed class WatchResponseContractTests + { + private const string SharedContractPath = "tests/contracts/watch_response_contract.json"; + + [Test] + public void WatchResponse_WhenSerialized_MatchesSharedContractFieldShape() + { + // Verifies C# does not add, remove, or rename watch response fields without updating + // the shared CLI contract. + JObject expected = ReadSharedContractFieldShape(); + WatchResponse response = new() + { + Success = true, + Id = "speed", + Expression = "1 + 2", + MaxHistory = 20, + HistoryDroppedCount = 0, + ClearedCount = 0, + Message = "Watch values retrieved.", + Watches = new List + { + new() + { + Id = "speed", + Expression = "1 + 2", + MaxHistory = 20, + HistoryDroppedCount = 0, + History = new List + { + new() + { + FrameCount = 42, + EvaluatedAtUtc = "2026-06-03T00:00:01.0000000Z", + Success = true, + Value = "3", + ErrorTypeName = "", + ErrorMessage = "" + } + } + } + }, + CompilationErrors = new List() + }; + string json = JsonConvert.SerializeObject( + response, + Formatting.None, + UnityCliLoopJsonResponseSerializerSettings.Settings); + JObject actual = NormalizeFieldShape(JObject.Parse(json)); + + Assert.That(JToken.DeepEquals(actual, expected), Is.True, $"Expected {expected} but got {actual}"); + } + + private static JObject ReadSharedContractFieldShape() + { + string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); + string json = File.ReadAllText(Path.Combine(projectRoot, SharedContractPath)); + return NormalizeFieldShape(JObject.Parse(json)); + } + + private static JObject NormalizeFieldShape(JObject value) + { + JObject shape = new(); + foreach (JProperty property in value.Properties()) + { + shape[property.Name] = NormalizeTokenFieldShape(property.Value); + } + return shape; + } + + private static JToken NormalizeTokenFieldShape(JToken value) + { + if (value is JObject objectValue) + { + return NormalizeFieldShape(objectValue); + } + + if (value is JArray arrayValue) + { + JArray arrayShape = new(); + if (arrayValue.Count > 0) + { + arrayShape.Add(NormalizeTokenFieldShape(arrayValue[0])); + } + return arrayShape; + } + + return new JValue(NormalizeScalarType(value.Type)); + } + + private static string NormalizeScalarType(JTokenType type) + { + return type switch + { + JTokenType.Boolean => "boolean", + JTokenType.Float => "number", + JTokenType.Integer => "number", + JTokenType.Null => "null", + JTokenType.String => "string", + _ => "unknown" + }; + } + } +} diff --git a/Assets/Tests/Editor/WatchResponseContractTests.cs.meta b/Assets/Tests/Editor/WatchResponseContractTests.cs.meta new file mode 100644 index 000000000..758faa22f --- /dev/null +++ b/Assets/Tests/Editor/WatchResponseContractTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d383897e998b4e809ede5ea3d8d95c2b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/WatchToolTests.cs b/Assets/Tests/Editor/WatchToolTests.cs new file mode 100644 index 000000000..982818d7a --- /dev/null +++ b/Assets/Tests/Editor/WatchToolTests.cs @@ -0,0 +1,71 @@ +using System.Threading; +using System.Threading.Tasks; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies external watch tool input validation and empty-state responses. + /// + [TestFixture] + public sealed class WatchToolTests + { + [SetUp] + public void SetUp() + { + WatchExpressionServices.Registry.ClearAll(); + } + + [TearDown] + public void TearDown() + { + WatchExpressionServices.Registry.ClearAll(); + } + + /// + /// Verifies an empty watch identifier is rejected without compiling user code. + /// + [Test] + public void EnableAsync_WhenIdIsEmpty_ReturnsValidationFailure() + { + Task task = WatchUseCase.EnableAsync( + new EnableWatchSchema { Expression = "1 + 2" }, + CancellationToken.None); + + Assert.That(task.IsCompletedSuccessfully, Is.True); + Assert.That(task.Result.Success, Is.False); + Assert.That(task.Result.Message, Does.Contain("Id must not be null or empty")); + } + + /// + /// Verifies a non-positive history limit is rejected as external input validation. + /// + [Test] + public void EnableAsync_WhenMaxHistoryIsZero_ReturnsValidationFailure() + { + Task task = WatchUseCase.EnableAsync( + new EnableWatchSchema { Id = "speed", Expression = "1 + 2", MaxHistory = 0 }, + CancellationToken.None); + + Assert.That(task.IsCompletedSuccessfully, Is.True); + Assert.That(task.Result.Success, Is.False); + Assert.That(task.Result.Message, Does.Contain("between 1 and 100")); + } + + /// + /// Verifies get-watch-values returns a successful empty collection before registration. + /// + [Test] + public void GetValues_WhenNoWatchesAreRegistered_ReturnsEmptySuccess() + { + WatchResponse response = WatchUseCase.GetValues(new GetWatchValuesSchema()); + + Assert.That(response.Success, Is.True); + Assert.That(response.Watches, Is.Empty); + Assert.That(response.Message, Does.Contain("No watch expressions")); + } + } +} diff --git a/Assets/Tests/Editor/WatchToolTests.cs.meta b/Assets/Tests/Editor/WatchToolTests.cs.meta new file mode 100644 index 000000000..83d0d7331 --- /dev/null +++ b/Assets/Tests/Editor/WatchToolTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ebdccac0e584a4fe1973ca4a72ae710d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index 71bd2aa23..40442008b 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -75,6 +75,21 @@ While Unity is paused on a hit, `execute-dynamic-code` can read live captured re The holder clears when Unity resumes (not when you `Step` while still paused), when the matching pause point is cleared, when a new hit replaces the snapshot, or when PlayMode exits. After resume, `TryGetCapturedValue` returns `Found=false`. +## Watch Expressions + +Use watch expressions when the value should be evaluated automatically after each paused Play Mode Step: + +```bash +uloop enable-watch --id "speed" --expression "UloopPausePoint.TryGetCapturedValue(\"speed\").Value" --max-history 20 +uloop get-watch-values --id "speed" +``` + +`enable-watch` compiles the C# expression once, evaluates it immediately for a baseline, and then evaluates it once per changed `Time.frameCount` while Unity is both playing and paused. Multiple watches run in registration order. `enable-watch` rejects a duplicate id instead of overwriting; clear with `clear-watch --id ` before re-registering a changed expression. `clear-watch --id ` removes one watch; `clear-watch --all` removes all watches. `get-watch-values` without `--id` returns every registered watch. + +The expression may use `UloopPausePoint.TryGetCapturedValue("name")` to inspect the latest raw pause-point capture while paused. Each history entry includes the frame and either a stringified value or an explicit error type and message. A throwing expression is recorded as an error and does not stop the Editor update loop. `--max-history` accepts 1 through 100 and drops the oldest entries after the limit. + +Watch expressions are in-memory Editor state. A domain reload clears them, so re-register them after `uloop compile`, script recompilation, or an Editor restart. For reliable per-Step changes, keep the expression attached to a continuous pause point on an `Update` or `FixedUpdate` line and use `control-play-mode --action Step`. + ## Marker Types - `uloop enable-pause-point --file --line` patches the already-compiled method at a source line. No code edit or recompile is required. diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/AssemblyInfo.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/AssemblyInfo.cs index d95a6d1c4..ed3469c90 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/AssemblyInfo.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/AssemblyInfo.cs @@ -1,6 +1,7 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.Editor")] +[assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.Watch.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.Tests.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.Tests.PlayMode")] [assembly: InternalsVisibleTo("UnityCLILoop.Tests.Demo.Editor")] diff --git a/Packages/src/Editor/FirstPartyTools/UnityCLILoop.FirstPartyTools.Editor.asmdef b/Packages/src/Editor/FirstPartyTools/UnityCLILoop.FirstPartyTools.Editor.asmdef index ba50f844c..345da7585 100644 --- a/Packages/src/Editor/FirstPartyTools/UnityCLILoop.FirstPartyTools.Editor.asmdef +++ b/Packages/src/Editor/FirstPartyTools/UnityCLILoop.FirstPartyTools.Editor.asmdef @@ -18,7 +18,8 @@ "GUID:d427b32aad9cb44fc8e962437c9dbcd8", "GUID:679888bc3e9c44c19ab0d62ff8701dcf", "GUID:9c34b9a25e1464dec91601291eade23f", - "GUID:32708dd5a1f18474e8a4deddc56e8806" + "GUID:32708dd5a1f18474e8a4deddc56e8806", + "GUID:e3fd8b49afd6f4e3cae09011028922b4" ], "includePlatforms": [ "Editor" diff --git a/Packages/src/Editor/FirstPartyTools/AssemblyInfo.cs b/Packages/src/Editor/FirstPartyTools/Watch/AssemblyInfo.cs similarity index 100% rename from Packages/src/Editor/FirstPartyTools/AssemblyInfo.cs rename to Packages/src/Editor/FirstPartyTools/Watch/AssemblyInfo.cs diff --git a/Packages/src/Editor/FirstPartyTools/AssemblyInfo.cs.meta b/Packages/src/Editor/FirstPartyTools/Watch/AssemblyInfo.cs.meta similarity index 83% rename from Packages/src/Editor/FirstPartyTools/AssemblyInfo.cs.meta rename to Packages/src/Editor/FirstPartyTools/Watch/AssemblyInfo.cs.meta index 3a95c67fd..42299019c 100644 --- a/Packages/src/Editor/FirstPartyTools/AssemblyInfo.cs.meta +++ b/Packages/src/Editor/FirstPartyTools/Watch/AssemblyInfo.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 677d4e62a27ed41d38b8c594190b855c +guid: aa707e90be2954a678e2fc7369d04b47 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Packages/src/Editor/FirstPartyTools/Watch/UnityCLILoop.FirstPartyTools.Watch.Editor.asmdef b/Packages/src/Editor/FirstPartyTools/Watch/UnityCLILoop.FirstPartyTools.Watch.Editor.asmdef new file mode 100644 index 000000000..2ec3602cb --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Watch/UnityCLILoop.FirstPartyTools.Watch.Editor.asmdef @@ -0,0 +1,19 @@ +{ + "name": "UnityCLILoop.FirstPartyTools.Watch.Editor", + "rootNamespace": "io.github.hatayama.UnityCliLoop.FirstPartyTools", + "references": [ + "GUID:fc3fd32eddbee40e39c2d76dc184957b", + "GUID:afe86dd49995e46baa33e099a4d2ee1d" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": false, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Packages/src/Editor/FirstPartyTools/Watch/UnityCLILoop.FirstPartyTools.Watch.Editor.asmdef.meta b/Packages/src/Editor/FirstPartyTools/Watch/UnityCLILoop.FirstPartyTools.Watch.Editor.asmdef.meta new file mode 100644 index 000000000..8713f2c47 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Watch/UnityCLILoop.FirstPartyTools.Watch.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: e3fd8b49afd6f4e3cae09011028922b4 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Watch/WatchTools.cs b/Packages/src/Editor/FirstPartyTools/Watch/WatchTools.cs new file mode 100644 index 000000000..fefd56799 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Watch/WatchTools.cs @@ -0,0 +1,329 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Parameters for registering one C# watch expression. + /// + public sealed class EnableWatchSchema : UnityCliLoopToolSchema + { + public string Id { get; set; } = string.Empty; + public string Expression { get; set; } = string.Empty; + public int MaxHistory { get; set; } = WatchExpressionRegistry.DefaultMaxHistory; + } + + /// + /// Parameters for clearing one or all watch expressions. + /// + public sealed class ClearWatchSchema : UnityCliLoopToolSchema + { + public string Id { get; set; } = string.Empty; + public bool All { get; set; } + } + + /// + /// Parameters for reading one or all watch expression histories. + /// + public sealed class GetWatchValuesSchema : UnityCliLoopToolSchema + { + public string Id { get; set; } = string.Empty; + } + + /// + /// Shared response for watch registration, clearing, and value retrieval. + /// + public sealed class WatchResponse : UnityCliLoopToolResponse + { + public bool Success { get; set; } = true; + public string Id { get; set; } = string.Empty; + public string Expression { get; set; } = string.Empty; + public int MaxHistory { get; set; } + public int HistoryDroppedCount { get; set; } + public int ClearedCount { get; set; } + public string Message { get; set; } = string.Empty; + public IReadOnlyList Watches { get; set; } = + Array.Empty(); + public IReadOnlyList CompilationErrors { get; set; } = + Array.Empty(); + } + + /// + /// One registered watch expression and its bounded evaluation history. + /// + public sealed class WatchEntryResponse + { + public string Id { get; set; } = string.Empty; + public string Expression { get; set; } = string.Empty; + public int MaxHistory { get; set; } + public int HistoryDroppedCount { get; set; } + public IReadOnlyList History { get; set; } = + Array.Empty(); + + internal static WatchEntryResponse FromEntry(WatchExpressionEntry entry) + { + return new WatchEntryResponse + { + Id = entry.Id, + Expression = entry.Expression, + MaxHistory = entry.MaxHistory, + HistoryDroppedCount = entry.HistoryDroppedCount, + History = entry.CreateHistorySnapshot() + .Select(WatchHistoryResponse.FromEntry) + .ToList() + }; + } + } + + /// + /// One watch evaluation result attached to an Editor frame. + /// + public sealed class WatchHistoryResponse + { + public int FrameCount { get; set; } + public string EvaluatedAtUtc { get; set; } = string.Empty; + public bool Success { get; set; } + public string Value { get; set; } = string.Empty; + public string ErrorTypeName { get; set; } = string.Empty; + public string ErrorMessage { get; set; } = string.Empty; + + internal static WatchHistoryResponse FromEntry(WatchExpressionHistoryEntry entry) + { + WatchEvaluationResult result = entry.Result; + return new WatchHistoryResponse + { + FrameCount = entry.FrameCount, + EvaluatedAtUtc = entry.EvaluatedAtUtc.ToString("O"), + Success = result.Success, + Value = result.Success + ? result.Value == null ? "null" : result.Value.ToString() + : string.Empty, + ErrorTypeName = result.ErrorTypeName, + ErrorMessage = result.ErrorMessage + }; + } + } + + /// + /// One compile diagnostic returned when a watch expression cannot be registered. + /// + public sealed class WatchCompilationErrorResponse + { + public int Line { get; set; } + public int Column { get; set; } + public string Message { get; set; } = string.Empty; + public string ErrorCode { get; set; } = string.Empty; + + internal static WatchCompilationErrorResponse FromCompilationError(CompilationError error) + { + return new WatchCompilationErrorResponse + { + Line = error.Line, + Column = error.Column, + Message = error.Message, + ErrorCode = error.ErrorCode + }; + } + } + + /// + /// Exposes watch expression registration through the Unity CLI Loop tool catalog. + /// + [UnityCliLoopTool] + public sealed class EnableWatchTool : UnityCliLoopTool + { + public override string ToolName => UnityCliLoopConstants.TOOL_NAME_ENABLE_WATCH; + + protected override Task ExecuteAsync(EnableWatchSchema parameters, CancellationToken ct) + { + return WatchUseCase.EnableAsync(parameters, ct); + } + } + + /// + /// Exposes watch expression clearing through the Unity CLI Loop tool catalog. + /// + [UnityCliLoopTool] + public sealed class ClearWatchTool : UnityCliLoopTool + { + public override string ToolName => UnityCliLoopConstants.TOOL_NAME_CLEAR_WATCH; + + protected override Task ExecuteAsync(ClearWatchSchema parameters, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(WatchUseCase.Clear(parameters)); + } + } + + /// + /// Exposes watch expression histories through the Unity CLI Loop tool catalog. + /// + [UnityCliLoopTool] + public sealed class GetWatchValuesTool : UnityCliLoopTool + { + public override string ToolName => UnityCliLoopConstants.TOOL_NAME_GET_WATCH_VALUES; + + protected override Task ExecuteAsync(GetWatchValuesSchema parameters, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(WatchUseCase.GetValues(parameters)); + } + } + + /// + /// Owns watch tool validation, compilation, registration, and response shaping. + /// + internal static class WatchUseCase + { + public static async Task EnableAsync(EnableWatchSchema parameters, CancellationToken ct) + { + string validationError = ValidateEnable(parameters); + if (validationError != null) + { + return CreateFailure(validationError); + } + + WatchCompilationResult compilationResult = await WatchExpressionServices.Compiler + .CompileAsync(parameters.Expression, ct); + if (!compilationResult.Success) + { + return CreateCompilationFailure(compilationResult); + } + + WatchRegistrationResult registrationResult = WatchExpressionServices.Registry.Register( + parameters.Id, + parameters.Expression, + compilationResult.Evaluator, + parameters.MaxHistory); + if (!registrationResult.Success) + { + return CreateFailure(registrationResult.ErrorMessage); + } + + WatchExpressionServices.EnsureMonitorStarted(); + WatchExpressionEntry entry = WatchExpressionServices.Registry.GetEntries() + .Single(candidate => candidate.Id == parameters.Id); + return WatchResponseFromEntry(entry, "Watch expression enabled."); + } + + public static WatchResponse Clear(ClearWatchSchema parameters) + { + if (parameters.All) + { + return new WatchResponse + { + ClearedCount = WatchExpressionServices.Registry.ClearAll(), + Message = "Watch expressions cleared." + }; + } + + if (string.IsNullOrWhiteSpace(parameters.Id)) + { + return CreateFailure("Id must not be null or empty unless All is true."); + } + + bool cleared = WatchExpressionServices.Registry.Clear(parameters.Id); + return cleared + ? new WatchResponse { Id = parameters.Id, Message = "Watch expression cleared." } + : CreateFailure($"Watch expression '{parameters.Id}' was not found."); + } + + public static WatchResponse GetValues(GetWatchValuesSchema parameters) + { + IReadOnlyList entries = WatchExpressionServices.Registry.GetEntries(); + if (!string.IsNullOrWhiteSpace(parameters.Id)) + { + entries = entries.Where(entry => entry.Id == parameters.Id).ToList(); + if (entries.Count == 0) + { + return CreateFailure($"Watch expression '{parameters.Id}' was not found."); + } + } + + return new WatchResponse + { + Watches = entries.Select(WatchEntryResponse.FromEntry).ToList(), + Message = entries.Count == 0 ? "No watch expressions are registered." : "Watch values retrieved." + }; + } + + private static string ValidateEnable(EnableWatchSchema parameters) + { + if (string.IsNullOrWhiteSpace(parameters.Id)) + { + return "Id must not be null or empty."; + } + + if (string.IsNullOrWhiteSpace(parameters.Expression)) + { + return "Expression must not be null or empty."; + } + + if (parameters.MaxHistory <= 0 || parameters.MaxHistory > WatchExpressionRegistry.MaxHistoryLimit) + { + return $"MaxHistory must be between 1 and {WatchExpressionRegistry.MaxHistoryLimit}."; + } + + return null; + } + + private static WatchResponse CreateCompilationFailure(WatchCompilationResult result) + { + return new WatchResponse + { + Success = false, + Message = result.ErrorMessage, + CompilationErrors = result.CompilationErrors + .Select(WatchCompilationErrorResponse.FromCompilationError) + .ToList() + }; + } + + private static WatchResponse CreateFailure(string message) + { + return new WatchResponse + { + Success = false, + Message = message + }; + } + + private static WatchResponse WatchResponseFromEntry(WatchExpressionEntry entry, string message) + { + WatchEntryResponse entryResponse = WatchEntryResponse.FromEntry(entry); + return new WatchResponse + { + Id = entryResponse.Id, + Expression = entryResponse.Expression, + MaxHistory = entryResponse.MaxHistory, + HistoryDroppedCount = entryResponse.HistoryDroppedCount, + Watches = new[] { entryResponse }, + Message = message + }; + } + } + + /// + /// Holds the domain-scoped watch registry, compiler, and Editor update monitor. + /// + internal static class WatchExpressionServices + { + private static readonly UnityWatchEditorStateProvider StateProvider = new(); + private static readonly WatchExpressionRegistry RegistryValue = new(StateProvider); + private static readonly WatchExpressionCompiler CompilerValue = new(new DynamicCodeCompiler()); + private static readonly WatchExpressionStepMonitor Monitor = new(RegistryValue); + + public static WatchExpressionRegistry Registry => RegistryValue; + public static WatchExpressionCompiler Compiler => CompilerValue; + + public static void EnsureMonitorStarted() + { + Monitor.Start(); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Watch/WatchTools.cs.meta b/Packages/src/Editor/FirstPartyTools/Watch/WatchTools.cs.meta new file mode 100644 index 000000000..a02410cc0 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Watch/WatchTools.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 571014bcc59e74e5e8c98dfdda8b8bb5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs index 0fb8fbe16..37d743ae2 100644 --- a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs +++ b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs @@ -67,6 +67,9 @@ public static UnityEditor.PackageManager.PackageInfo PackageInfo public const string TOOL_NAME_GET_HIERARCHY = "get-hierarchy"; public const string TOOL_NAME_GET_LOGS = "get-logs"; public const string TOOL_NAME_RUN_TESTS = "run-tests"; + public const string TOOL_NAME_ENABLE_WATCH = "enable-watch"; + public const string TOOL_NAME_CLEAR_WATCH = "clear-watch"; + public const string TOOL_NAME_GET_WATCH_VALUES = "get-watch-values"; public const string COMMAND_NAME_GET_TOOL_DETAILS = "get-tool-details"; public const string COMMAND_NAME_GET_VERSION = "get-version"; public const string COMMAND_NAME_GET_COMPILE_STATUS = "get-compile-status"; diff --git a/cli/common/tools/default-tools.json b/cli/common/tools/default-tools.json index 2ff709a17..c36516930 100644 --- a/cli/common/tools/default-tools.json +++ b/cli/common/tools/default-tools.json @@ -396,6 +396,58 @@ } } }, + { + "name": "enable-watch", + "description": "Register a C# expression to evaluate on each paused Play Mode step", + "inputSchema": { + "type": "object", + "properties": { + "Id": { + "type": "string", + "description": "Unique watch expression identifier" + }, + "Expression": { + "type": "string", + "description": "C# expression returning an object; UloopPausePoint.TryGetCapturedValue can read the latest raw capture" + }, + "MaxHistory": { + "type": "integer", + "description": "Maximum number of watch evaluations to retain (1-100)", + "default": 20 + } + } + } + }, + { + "name": "clear-watch", + "description": "Clear one or all registered C# watch expressions", + "inputSchema": { + "type": "object", + "properties": { + "Id": { + "type": "string", + "description": "Watch expression identifier to clear" + }, + "All": { + "type": "boolean", + "description": "Clear every registered watch expression" + } + } + } + }, + { + "name": "get-watch-values", + "description": "Show registered watch expression values and bounded evaluation history", + "inputSchema": { + "type": "object", + "properties": { + "Id": { + "type": "string", + "description": "Optional watch expression identifier; omit to return all watches" + } + } + } + }, { "name": "simulate-mouse-ui", "description": "Simulate mouse click, long-press, and drag on PlayMode UI elements via EventSystem screen coordinates", diff --git a/cli/dispatcher/internal/dispatcher/help_test.go b/cli/dispatcher/internal/dispatcher/help_test.go index c1a1c158d..ee119d804 100644 --- a/cli/dispatcher/internal/dispatcher/help_test.go +++ b/cli/dispatcher/internal/dispatcher/help_test.go @@ -273,6 +273,24 @@ func TestCommandHelpPrefersProjectCacheForDefaultToolNames(t *testing.T) { } } +// Verifies enable-watch stays a plain default tool and still exposes schema-driven option help. +func TestCommandHelpUsesWatchToolSchemaForDefaultWatchCommands(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + handled, code := tryHandleCommandHelp("enable-watch", "", "", &stdout, &stderr) + + if !handled || code != 0 { + t.Fatalf("watch help was not handled: handled=%v code=%d stderr=%s", handled, code, stderr.String()) + } + output := stdout.String() + for _, expected := range []string{"--expression", "--id", "--max-history"} { + if !strings.Contains(output, expected) { + t.Fatalf("watch help missing %q:\n%s", expected, output) + } + } +} + // Tests that execute-dynamic-code help includes CLI-side code-file support without resolving a Unity project. func TestRunDispatcherExecuteDynamicCodeHelpDoesNotRequireUnityProject(t *testing.T) { t.Chdir(t.TempDir()) diff --git a/cli/project-runner/internal/projectrunner/watch_response_contract_test.go b/cli/project-runner/internal/projectrunner/watch_response_contract_test.go new file mode 100644 index 000000000..4dcea5348 --- /dev/null +++ b/cli/project-runner/internal/projectrunner/watch_response_contract_test.go @@ -0,0 +1,35 @@ +package projectrunner + +import ( + "encoding/json" + "os" + "reflect" + "testing" +) + +const watchResponseContractPath = "tests/contracts/watch_response_contract.json" + +// Verifies the Go watchResponse DTO preserves every field in the shared Unity watch response contract. +func TestWatchResponseMatchesSharedContract(t *testing.T) { + fixturePath := findRepoRelativeFile(t, watchResponseContractPath) + fixture, err := os.ReadFile(fixturePath) + if err != nil { + t.Fatalf("failed to read shared watch response contract: %v", err) + } + + var response watchResponse + if err := json.Unmarshal(fixture, &response); err != nil { + t.Fatalf("failed to unmarshal shared watch response contract: %v", err) + } + + roundTripped, err := json.Marshal(response) + if err != nil { + t.Fatalf("failed to marshal Go watch response: %v", err) + } + + expected := readJSONFieldShape(t, fixture) + actual := readJSONFieldShape(t, roundTripped) + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("watch response field shape drifted\nexpected: %#v\nactual: %#v", expected, actual) + } +} diff --git a/cli/project-runner/internal/projectrunner/watch_types.go b/cli/project-runner/internal/projectrunner/watch_types.go new file mode 100644 index 000000000..0c3b9bb51 --- /dev/null +++ b/cli/project-runner/internal/projectrunner/watch_types.go @@ -0,0 +1,37 @@ +package projectrunner + +type watchResponse struct { + Success bool `json:"Success"` + Id string `json:"Id"` + Expression string `json:"Expression"` + MaxHistory int `json:"MaxHistory"` + HistoryDroppedCount int `json:"HistoryDroppedCount"` + ClearedCount int `json:"ClearedCount"` + Message string `json:"Message"` + Watches []watchEntryResponse `json:"Watches"` + CompilationErrors []watchCompilationErrorResponse `json:"CompilationErrors"` +} + +type watchEntryResponse struct { + Id string `json:"Id"` + Expression string `json:"Expression"` + MaxHistory int `json:"MaxHistory"` + HistoryDroppedCount int `json:"HistoryDroppedCount"` + History []watchHistoryResponse `json:"History"` +} + +type watchHistoryResponse struct { + FrameCount int `json:"FrameCount"` + EvaluatedAtUtc string `json:"EvaluatedAtUtc"` + Success bool `json:"Success"` + Value string `json:"Value"` + ErrorTypeName string `json:"ErrorTypeName"` + ErrorMessage string `json:"ErrorMessage"` +} + +type watchCompilationErrorResponse struct { + Line int `json:"Line"` + Column int `json:"Column"` + Message string `json:"Message"` + ErrorCode string `json:"ErrorCode"` +} diff --git a/tests/contracts/watch_response_contract.json b/tests/contracts/watch_response_contract.json new file mode 100644 index 000000000..958ccaf84 --- /dev/null +++ b/tests/contracts/watch_response_contract.json @@ -0,0 +1,28 @@ +{ + "Success": true, + "Id": "speed", + "Expression": "1 + 2", + "MaxHistory": 20, + "HistoryDroppedCount": 0, + "ClearedCount": 0, + "Message": "Watch values retrieved.", + "Watches": [ + { + "Id": "speed", + "Expression": "1 + 2", + "MaxHistory": 20, + "HistoryDroppedCount": 0, + "History": [ + { + "FrameCount": 42, + "EvaluatedAtUtc": "2026-06-03T00:00:01.0000000Z", + "Success": true, + "Value": "3", + "ErrorTypeName": "", + "ErrorMessage": "" + } + ] + } + ], + "CompilationErrors": [] +} From dd92d61073c070e875953af657f9b4790fbad7a7 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 12 Jul 2026 21:34:55 +0900 Subject: [PATCH 3/3] Populate watch contract CompilationErrors for nested field-shape coverage An empty CompilationErrors array left WatchCompilationErrorResponse unverified on both C# and Go contract tests. Use the real Line/Column/Message/ErrorCode fields so the shared fixture exercises the nested shape. Co-authored-by: Cursor --- Assets/Tests/Editor/WatchResponseContractTests.cs | 11 ++++++++++- tests/contracts/watch_response_contract.json | 9 ++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Assets/Tests/Editor/WatchResponseContractTests.cs b/Assets/Tests/Editor/WatchResponseContractTests.cs index 20c5c6ab2..22466e66f 100644 --- a/Assets/Tests/Editor/WatchResponseContractTests.cs +++ b/Assets/Tests/Editor/WatchResponseContractTests.cs @@ -54,7 +54,16 @@ public void WatchResponse_WhenSerialized_MatchesSharedContractFieldShape() } } }, - CompilationErrors = new List() + CompilationErrors = new List + { + new() + { + Line = 3, + Column = 25, + Message = "; expected", + ErrorCode = "CS1002" + } + } }; string json = JsonConvert.SerializeObject( response, diff --git a/tests/contracts/watch_response_contract.json b/tests/contracts/watch_response_contract.json index 958ccaf84..039df49c8 100644 --- a/tests/contracts/watch_response_contract.json +++ b/tests/contracts/watch_response_contract.json @@ -24,5 +24,12 @@ ] } ], - "CompilationErrors": [] + "CompilationErrors": [ + { + "Line": 3, + "Column": 25, + "Message": "; expected", + "ErrorCode": "CS1002" + } + ] }