From cb6440476ccc75d4abadcf74a36272e7c8ed1e00 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 19 Jul 2026 23:27:16 +0900 Subject: [PATCH] Hint when a watch expression's value looks frozen Watch values only refresh while the Editor is paused on a changed frame, so a value that stays the same across several evaluations usually means the linked pause point has not been hit again - a recurring source of confusion in prior verification passes. Add a pure WatchValueFreezeHintEvaluator and surface its result as ValueFrozenHint on each watch entry, and mirror the new field in the shared Go/C# watch response contract. --- .../Editor/WatchResponseContractTests.cs | 1 + .../WatchValueFreezeHintEvaluatorTests.cs | 107 ++++++++++++++++++ ...WatchValueFreezeHintEvaluatorTests.cs.meta | 11 ++ .../FirstPartyTools/Watch/WatchTools.cs | 14 ++- .../Watch/WatchValueFreezeHintEvaluator.cs | 43 +++++++ .../WatchValueFreezeHintEvaluator.cs.meta | 11 ++ .../internal/projectrunner/watch_types.go | 1 + tests/contracts/watch_response_contract.json | 1 + 8 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 Assets/Tests/Editor/WatchValueFreezeHintEvaluatorTests.cs create mode 100644 Assets/Tests/Editor/WatchValueFreezeHintEvaluatorTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Watch/WatchValueFreezeHintEvaluator.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Watch/WatchValueFreezeHintEvaluator.cs.meta diff --git a/Assets/Tests/Editor/WatchResponseContractTests.cs b/Assets/Tests/Editor/WatchResponseContractTests.cs index 22466e66f3..678c4d4f33 100644 --- a/Assets/Tests/Editor/WatchResponseContractTests.cs +++ b/Assets/Tests/Editor/WatchResponseContractTests.cs @@ -40,6 +40,7 @@ public void WatchResponse_WhenSerialized_MatchesSharedContractFieldShape() Expression = "1 + 2", MaxHistory = 20, HistoryDroppedCount = 0, + ValueFrozenHint = "", History = new List { new() diff --git a/Assets/Tests/Editor/WatchValueFreezeHintEvaluatorTests.cs b/Assets/Tests/Editor/WatchValueFreezeHintEvaluatorTests.cs new file mode 100644 index 0000000000..53846f2feb --- /dev/null +++ b/Assets/Tests/Editor/WatchValueFreezeHintEvaluatorTests.cs @@ -0,0 +1,107 @@ +using System.Collections.Generic; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies WatchValueFreezeHintEvaluator's detection of a watch value that looks frozen. + /// + [TestFixture] + public sealed class WatchValueFreezeHintEvaluatorTests + { + [Test] + public void EvaluateFreezeHint_WhenHistoryHasFewerEntriesThanTheThreshold_ReturnsEmpty() + { + // Tests that a short history (not enough evaluations to judge staleness) never hints. + List history = CreateHistory("1", "1"); + + string hint = WatchValueFreezeHintEvaluator.EvaluateFreezeHint(history); + + Assert.That(hint, Is.Empty); + } + + [Test] + public void EvaluateFreezeHint_WhenRecentValuesAreIdentical_ReturnsHint() + { + // Tests the core case: the last several evaluations returned the same value. + List history = CreateHistory("1", "1", "1"); + + string hint = WatchValueFreezeHintEvaluator.EvaluateFreezeHint(history); + + Assert.That(hint, Is.Not.Empty); + Assert.That(hint, Does.Contain("pause point")); + } + + [Test] + public void EvaluateFreezeHint_WhenRecentValuesDiffer_ReturnsEmpty() + { + // Tests that a genuinely changing value is never flagged as frozen. + List history = CreateHistory("1", "2", "3"); + + string hint = WatchValueFreezeHintEvaluator.EvaluateFreezeHint(history); + + Assert.That(hint, Is.Empty); + } + + [Test] + public void EvaluateFreezeHint_WhenOnlyAnOlderEntryDiffers_ReturnsHintFromRecentWindowOnly() + { + // Tests that the check looks at the trailing window, not the whole history: an old + // change that happened before the most recent repeats must not suppress the hint. + List history = CreateHistory("0", "1", "1", "1"); + + string hint = WatchValueFreezeHintEvaluator.EvaluateFreezeHint(history); + + Assert.That(hint, Is.Not.Empty); + } + + [Test] + public void EvaluateFreezeHint_WhenARecentEvaluationFailed_ReturnsEmpty() + { + // Tests that evaluation errors are not mistaken for a frozen value; a failure is a + // distinct problem the freeze hint should not paper over. + List history = new() + { + CreateEntry("1", success: true), + CreateEntry("1", success: true), + CreateEntry(string.Empty, success: false) + }; + + string hint = WatchValueFreezeHintEvaluator.EvaluateFreezeHint(history); + + Assert.That(hint, Is.Empty); + } + + [Test] + public void EvaluateFreezeHint_WhenHistoryIsNull_ReturnsEmpty() + { + // Tests the defensive null-history path used before any evaluation has occurred. + string hint = WatchValueFreezeHintEvaluator.EvaluateFreezeHint(null); + + Assert.That(hint, Is.Empty); + } + + private static List CreateHistory(params string[] values) + { + List history = new(); + foreach (string value in values) + { + history.Add(CreateEntry(value, success: true)); + } + + return history; + } + + private static WatchHistoryResponse CreateEntry(string value, bool success) + { + return new WatchHistoryResponse + { + Success = success, + Value = value + }; + } + } +} diff --git a/Assets/Tests/Editor/WatchValueFreezeHintEvaluatorTests.cs.meta b/Assets/Tests/Editor/WatchValueFreezeHintEvaluatorTests.cs.meta new file mode 100644 index 0000000000..1f77c5954d --- /dev/null +++ b/Assets/Tests/Editor/WatchValueFreezeHintEvaluatorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 79660873126eb4e0da7759fb832776ab +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Watch/WatchTools.cs b/Packages/src/Editor/FirstPartyTools/Watch/WatchTools.cs index 47487829fc..6be5bc7b53 100644 --- a/Packages/src/Editor/FirstPartyTools/Watch/WatchTools.cs +++ b/Packages/src/Editor/FirstPartyTools/Watch/WatchTools.cs @@ -64,17 +64,25 @@ public sealed class WatchEntryResponse public IReadOnlyList History { get; set; } = Array.Empty(); + /// + /// Non-empty when recent evaluations returned the same value, suggesting the linked + /// pause point has not been hit again since watches only refresh on a changed, paused frame. + /// + public string ValueFrozenHint { get; set; } = string.Empty; + internal static WatchEntryResponse FromEntry(WatchExpressionEntry entry) { + List history = entry.CreateHistorySnapshot() + .Select(WatchHistoryResponse.FromEntry) + .ToList(); return new WatchEntryResponse { Id = entry.Id, Expression = entry.Expression, MaxHistory = entry.MaxHistory, HistoryDroppedCount = entry.HistoryDroppedCount, - History = entry.CreateHistorySnapshot() - .Select(WatchHistoryResponse.FromEntry) - .ToList() + History = history, + ValueFrozenHint = WatchValueFreezeHintEvaluator.EvaluateFreezeHint(history) }; } } diff --git a/Packages/src/Editor/FirstPartyTools/Watch/WatchValueFreezeHintEvaluator.cs b/Packages/src/Editor/FirstPartyTools/Watch/WatchValueFreezeHintEvaluator.cs new file mode 100644 index 0000000000..04fd10a081 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Watch/WatchValueFreezeHintEvaluator.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using System.Linq; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Detects a watch expression whose recent evaluations returned the same value, so + /// get-watch-values can explain that watches only refresh on a changed, paused frame - + /// a value that looks stuck usually means the linked pause point has not been hit again. + /// + internal static class WatchValueFreezeHintEvaluator + { + internal const int MinIdenticalEvaluationsForHint = 3; + + private const string FreezeHintMessageFormat = + "Value has not changed across the last {0} evaluations. Watch values only refresh " + + "when the Editor is paused on a changed frame, so confirm the linked pause point has " + + "been hit again if you expect this value to be different (a marker on a conditional " + + "line freezes after its first hit)."; + + public static string EvaluateFreezeHint(IReadOnlyList history) + { + if (history == null || history.Count < MinIdenticalEvaluationsForHint) + { + return string.Empty; + } + + List recent = history + .Skip(history.Count - MinIdenticalEvaluationsForHint) + .ToList(); + if (recent.Any(entry => !entry.Success)) + { + return string.Empty; + } + + string firstValue = recent[0].Value; + bool allIdentical = recent.All(entry => entry.Value == firstValue); + return allIdentical + ? string.Format(FreezeHintMessageFormat, MinIdenticalEvaluationsForHint) + : string.Empty; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Watch/WatchValueFreezeHintEvaluator.cs.meta b/Packages/src/Editor/FirstPartyTools/Watch/WatchValueFreezeHintEvaluator.cs.meta new file mode 100644 index 0000000000..1708ddb70b --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Watch/WatchValueFreezeHintEvaluator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ac90dfc9fb77540fcb9018a2224dfc9b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/cli/project-runner/internal/projectrunner/watch_types.go b/cli/project-runner/internal/projectrunner/watch_types.go index 0c3b9bb516..44f54851ff 100644 --- a/cli/project-runner/internal/projectrunner/watch_types.go +++ b/cli/project-runner/internal/projectrunner/watch_types.go @@ -17,6 +17,7 @@ type watchEntryResponse struct { Expression string `json:"Expression"` MaxHistory int `json:"MaxHistory"` HistoryDroppedCount int `json:"HistoryDroppedCount"` + ValueFrozenHint string `json:"ValueFrozenHint"` History []watchHistoryResponse `json:"History"` } diff --git a/tests/contracts/watch_response_contract.json b/tests/contracts/watch_response_contract.json index 039df49c84..972d35210d 100644 --- a/tests/contracts/watch_response_contract.json +++ b/tests/contracts/watch_response_contract.json @@ -12,6 +12,7 @@ "Expression": "1 + 2", "MaxHistory": 20, "HistoryDroppedCount": 0, + "ValueFrozenHint": "", "History": [ { "FrameCount": 42,