Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,8 @@ The expression may use `UloopPausePoint.TryGetCapturedValue("name")` to inspect

## Lifetime

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`.
Watch expressions survive a domain reload. They are saved in Editor session state, so `uloop compile`, a script recompilation, and a Play entry with Domain Reload enabled all keep them registered — the expression is recompiled after the reload and evaluation starts from a fresh baseline, so history collected before the reload is gone.

A watch whose expression no longer compiles after the reload (its type was renamed or deleted) is dropped, and the next `enable-watch` or `get-watch-values` response reports it in `Warning` with the id and the compiler message.

Watch expressions do not survive an Editor restart — session state ends with the Editor process. 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`.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,8 @@ The expression may use `UloopPausePoint.TryGetCapturedValue("name")` to inspect

## Lifetime

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`.
Watch expressions survive a domain reload. They are saved in Editor session state, so `uloop compile`, a script recompilation, and a Play entry with Domain Reload enabled all keep them registered — the expression is recompiled after the reload and evaluation starts from a fresh baseline, so history collected before the reload is gone.

A watch whose expression no longer compiles after the reload (its type was renamed or deleted) is dropped, and the next `enable-watch` or `get-watch-values` response reports it in `Warning` with the id and the compiler message.

Watch expressions do not survive an Editor restart — session state ends with the Editor process. 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`.
384 changes: 384 additions & 0 deletions Assets/Tests/Editor/WatchRestoreServiceTests.cs

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions Assets/Tests/Editor/WatchRestoreServiceTests.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

115 changes: 115 additions & 0 deletions Assets/Tests/Editor/WatchSessionStateStoreTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
using System;
using System.Collections.Generic;

using NUnit.Framework;

using UnityEditor;

using io.github.hatayama.UnityCliLoop.FirstPartyTools;

namespace io.github.hatayama.UnityCliLoop.Tests.Editor
{
/// <summary>
/// Verifies watch expressions round-trip through SessionState and that unusable stored
/// content degrades to "no watches to restore" instead of failing Editor startup.
/// </summary>
[TestFixture]
public sealed class WatchSessionStateStoreTests
{
private const string RecordsKey = "io.github.hatayama.uloopmcp.watch.persistedRecords";

private string _originalValue;

[SetUp]
public void SetUp()
{
_originalValue = SessionState.GetString(RecordsKey, string.Empty);
SessionState.EraseString(RecordsKey);
}

[TearDown]
public void TearDown()
{
SessionState.SetString(RecordsKey, _originalValue);
}

/// <summary>
/// What: expressions containing newlines, quotes, and non-ASCII survive the round trip byte for byte.
/// </summary>
[Test]
public void SaveThenLoad_WithAwkwardExpressionText_RoundTripsEveryField()
{
const string awkwardExpression = "\"quoted\" + \n\t'x' + \"\\u00e9\\u3042\"";
WatchSessionStateStore store = new();

store.Save(new List<WatchPersistedRecord>
{
new() { Id = "first", Expression = awkwardExpression, MaxHistory = 7 },
new() { Id = "second", Expression = "1 + 2", MaxHistory = 20 }
});
IReadOnlyList<WatchPersistedRecord> loaded = store.Load();

Assert.That(loaded, Has.Count.EqualTo(2));
Assert.That(loaded[0].Id, Is.EqualTo("first"));
Assert.That(loaded[0].Expression, Is.EqualTo(awkwardExpression));
Assert.That(loaded[0].MaxHistory, Is.EqualTo(7));
Assert.That(loaded[1].Id, Is.EqualTo("second"));
}

/// <summary>
/// What: saving an empty list clears the store rather than leaving the previous records behind.
/// </summary>
[Test]
public void Save_WithEmptyList_LeavesNothingToRestore()
{
WatchSessionStateStore store = new();
store.Save(new List<WatchPersistedRecord>
{
new() { Id = "first", Expression = "1 + 2", MaxHistory = 20 }
});

store.Save(Array.Empty<WatchPersistedRecord>());

Assert.That(store.Load(), Is.Empty);
}

/// <summary>
/// What: a record without an expression cannot be recompiled, so saving it is rejected.
/// </summary>
[Test]
public void Save_WithNullExpression_ThrowsBecauseTheRecordCouldNeverBeRestored()
{
WatchSessionStateStore store = new();

Assert.That(
() => store.Save(new List<WatchPersistedRecord>
{
new() { Id = "first", Expression = null, MaxHistory = 20 }
}),
Throws.ArgumentException);
}

/// <summary>
/// What: an unset key means no watches were persisted, not an error.
/// </summary>
[Test]
public void Load_WhenKeyWasNeverWritten_ReturnsEmpty()
{
WatchSessionStateStore store = new();

Assert.That(store.Load(), Is.Empty);
}

/// <summary>
/// What: content written by another uloop generation degrades to empty instead of throwing.
/// </summary>
[Test]
public void Load_WhenStoredJsonIsNotAWatchRecordList_ReturnsEmpty()
{
SessionState.SetString(RecordsKey, "{ this is not json");
WatchSessionStateStore store = new();

Assert.That(store.Load(), Is.Empty);
}
}
}
11 changes: 11 additions & 0 deletions Assets/Tests/Editor/WatchSessionStateStoreTests.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

187 changes: 186 additions & 1 deletion Assets/Tests/Editor/WatchToolTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

Expand All @@ -8,21 +10,27 @@
namespace io.github.hatayama.UnityCliLoop.Tests.Editor
{
/// <summary>
/// Verifies external watch tool input validation and empty-state responses.
/// Verifies external watch tool input validation, empty-state responses, persistence of the
/// registry across watch commands, and how a domain-reload restore report reaches the caller.
/// </summary>
[TestFixture]
public sealed class WatchToolTests
{
private InMemoryWatchPersistenceStore _store;

[SetUp]
public void SetUp()
{
WatchExpressionServices.Registry.ClearAll();
_store = new InMemoryWatchPersistenceStore();
WatchExpressionServices.OverrideStoreForTesting(_store);
}

[TearDown]
public void TearDown()
{
WatchExpressionServices.Registry.ClearAll();
WatchExpressionServices.ResetForTesting();
}

/// <summary>
Expand Down Expand Up @@ -55,6 +63,140 @@ public void EnableAsync_WhenMaxHistoryIsZero_ReturnsValidationFailure()
Assert.That(task.Result.Message, Does.Contain("between 1 and 100"));
}

/// <summary>
/// What: a rejected registration leaves the persisted records untouched.
/// </summary>
[Test]
public void EnableAsync_WhenValidationFails_DoesNotTouchThePersistedRecords()
{
Task<WatchResponse> task = WatchUseCase.EnableAsync(
new EnableWatchSchema { Id = "speed", Expression = string.Empty },
CancellationToken.None);

Assert.That(task.Result.Success, Is.False);
Assert.That(_store.SaveCount, Is.EqualTo(0));
}

/// <summary>
/// What: a successful enable-watch persists the watch, so the next domain reload restores it.
/// </summary>
[Test]
public void EnableAsync_WhenRegistrationSucceeds_PersistsTheWatchForTheNextDomainReload()
{
WatchExpressionServices.OverrideCompilerForTesting(new StubWatchExpressionCompiler());

Task<WatchResponse> task = WatchUseCase.EnableAsync(
new EnableWatchSchema { Id = "speed", Expression = "1 + 2", MaxHistory = 9 },
CancellationToken.None);

Assert.That(task.IsCompletedSuccessfully, Is.True);
Assert.That(task.Result.Success, Is.True);
Assert.That(_store.Records, Has.Count.EqualTo(1));
Assert.That(_store.Records[0].Id, Is.EqualTo("speed"));
Assert.That(_store.Records[0].Expression, Is.EqualTo("1 + 2"));
Assert.That(_store.Records[0].MaxHistory, Is.EqualTo(9));
}

/// <summary>
/// What: asking for exactly the watch the reload dropped explains why it is gone.
/// </summary>
[Test]
public void GetValues_WhenTheRequestedWatchWasDroppedByTheRestore_ExplainsItInWarning()
{
WatchExpressionServices.SetLastRestoreReportForTesting(
new WatchRestoreReport(0, new[] { "Watch 'speed' was not restored." }));

WatchResponse response = WatchUseCase.GetValues(new GetWatchValuesSchema { Id = "speed" });

Assert.That(response.Success, Is.False);
Assert.That(response.Warning, Does.Contain("Watch 'speed' was not restored."));
}

/// <summary>
/// What: the registry snapshot written for the next domain reload carries every restore input.
/// </summary>
[Test]
public void SaveRegistrySnapshot_AfterRegistration_PersistsIdExpressionAndHistoryLimit()
{
WatchExpressionServices.Registry.Register(
"speed",
"1 + 2",
new ConstantWatchExpressionEvaluator(3),
7);

WatchExpressionServices.SaveRegistrySnapshot();

Assert.That(_store.Records, Has.Count.EqualTo(1));
Assert.That(_store.Records[0].Id, Is.EqualTo("speed"));
Assert.That(_store.Records[0].Expression, Is.EqualTo("1 + 2"));
Assert.That(_store.Records[0].MaxHistory, Is.EqualTo(7));
}

/// <summary>
/// What: clearing one watch removes it from the records the next domain reload would restore.
/// </summary>
[Test]
public void Clear_WhenAWatchIsCleared_RemovesItFromThePersistedRecords()
{
WatchExpressionServices.Registry.Register(
"speed",
"1 + 2",
new ConstantWatchExpressionEvaluator(3),
20);
WatchExpressionServices.SaveRegistrySnapshot();

WatchResponse response = WatchUseCase.Clear(new ClearWatchSchema { Id = "speed" });

Assert.That(response.Success, Is.True);
Assert.That(_store.Records, Is.Empty);
}

/// <summary>
/// What: clearing every watch empties the persisted records too.
/// </summary>
[Test]
public void Clear_WithAll_EmptiesThePersistedRecords()
{
WatchExpressionServices.Registry.Register(
"speed",
"1 + 2",
new ConstantWatchExpressionEvaluator(3),
20);
WatchExpressionServices.SaveRegistrySnapshot();

WatchResponse response = WatchUseCase.Clear(new ClearWatchSchema { All = true });

Assert.That(response.ClearedCount, Is.EqualTo(1));
Assert.That(_store.Records, Is.Empty);
}

/// <summary>
/// What: watches dropped by the last domain reload are reported on the next value read.
/// </summary>
[Test]
public void GetValues_WhenTheLastRestoreDroppedWatches_ReportsThemInWarning()
{
WatchExpressionServices.SetLastRestoreReportForTesting(
new WatchRestoreReport(0, new[] { "Watch 'speed' was not restored.", "Watch 'hp' was not restored." }));

WatchResponse response = WatchUseCase.GetValues(new GetWatchValuesSchema());

Assert.That(response.Warning, Does.Contain("Watch 'speed' was not restored."));
Assert.That(response.Warning, Does.Contain("Watch 'hp' was not restored."));
}

/// <summary>
/// What: with no restore report there is no Warning, and the field is omitted from the response JSON.
/// </summary>
[Test]
public void GetValues_WhenNoRestoreHasRun_LeavesWarningUnset()
{
WatchResponse response = WatchUseCase.GetValues(new GetWatchValuesSchema());

Assert.That(response.Warning, Is.Null);
Assert.That(response.ShouldSerializeWarning(), Is.False);
}

/// <summary>
/// Verifies get-watch-values returns a successful empty collection before registration.
/// </summary>
Expand All @@ -67,5 +209,48 @@ public void GetValues_WhenNoWatchesAreRegistered_ReturnsEmptySuccess()
Assert.That(response.Watches, Is.Empty);
Assert.That(response.Message, Does.Contain("No watch expressions"));
}

private sealed class InMemoryWatchPersistenceStore : IWatchPersistenceStore
{
public IReadOnlyList<WatchPersistedRecord> Records { get; private set; } =
Array.Empty<WatchPersistedRecord>();

public int SaveCount { get; private set; }

public IReadOnlyList<WatchPersistedRecord> Load()
{
return Records;
}

public void Save(IReadOnlyList<WatchPersistedRecord> records)
{
SaveCount++;
Records = records;
}
}

private sealed class StubWatchExpressionCompiler : IWatchExpressionCompiler
{
public Task<WatchCompilationResult> CompileAsync(string expression, CancellationToken ct)
{
return Task.FromResult(
WatchCompilationResult.SuccessResult(new ConstantWatchExpressionEvaluator(3)));
}
}

private sealed class ConstantWatchExpressionEvaluator : IWatchExpressionEvaluator
{
private readonly object _value;

public ConstantWatchExpressionEvaluator(object value)
{
_value = value;
}

public WatchEvaluationResult Evaluate()
{
return WatchEvaluationResult.SuccessResult(_value);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,8 @@ The expression may use `UloopPausePoint.TryGetCapturedValue("name")` to inspect

## Lifetime

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`.
Watch expressions survive a domain reload. They are saved in Editor session state, so `uloop compile`, a script recompilation, and a Play entry with Domain Reload enabled all keep them registered — the expression is recompiled after the reload and evaluation starts from a fresh baseline, so history collected before the reload is gone.

A watch whose expression no longer compiles after the reload (its type was renamed or deleted) is dropped, and the next `enable-watch` or `get-watch-values` response reports it in `Warning` with the id and the compiler message.

Watch expressions do not survive an Editor restart — session state ends with the Editor process. 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`.
Loading
Loading