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
19 changes: 19 additions & 0 deletions .agents/skills/uloop-pause-point/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,25 @@ uloop await-pause-point --id "Assets/Scripts/Enemy.cs:42" --timeout-seconds 30
5. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot.
6. Clear the marker with `uloop clear-pause-point --id "Assets/Scripts/Enemy.cs:42"` or stop PlayMode before moving on. Use `uloop clear-pause-point --all` to clear every active marker at once, for example when resetting between E2E scenarios. Clearing also removes the underlying code patch, so the method runs untouched afterwards.

## Capture Modes and History

Choose the capture mode when enabling a pause point:

- `single-shot` is the default. The first hit pauses Unity and disarms the marker.
- `continuous` pauses Unity on every hit and remains armed. Each hit adds a frame to `CapturedVariableHistory`, while `CapturedVariables` remains the latest-hit compatibility view.
- `trace` remains armed and records each hit without pausing Unity.

`--max-history` defaults to 20 and accepts values from 1 through 100. When the limit is exceeded, the oldest frames are dropped and `HistoryDroppedCount` reports how many were removed. `pause-point-status` returns the current `Mode`, `MaxHistory`, history frames, and dropped count.

To inspect value changes one Editor Step at a time, enable a `continuous` pause point on a line inside `Update` or `FixedUpdate`, trigger the first hit, then run:

```bash
uloop control-play-mode --action Step
uloop pause-point-status --id "Assets/Scripts/Enemy.cs:42"
```

Repeat the Step/status pair to inspect the history tail. A new frame is captured only when the patched line executes during that frame; event handlers such as `OnCollisionEnter` update only when the event occurs again. Use a longer `--timeout-seconds` for a Step session because the enable-time timeout does not extend after hits.

## Reading CapturedVariables

Every hit response embeds `CapturedVariables`: the method's in-scope locals, its parameters, and the `this` instance fields, captured at the exact moment execution reached the patched line. Values are point-in-time strings, not live references, so they stay valid as evidence even after Unity resumes.
Expand Down
19 changes: 19 additions & 0 deletions .claude/skills/uloop-pause-point/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,25 @@ uloop await-pause-point --id "Assets/Scripts/Enemy.cs:42" --timeout-seconds 30
5. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot.
6. Clear the marker with `uloop clear-pause-point --id "Assets/Scripts/Enemy.cs:42"` or stop PlayMode before moving on. Use `uloop clear-pause-point --all` to clear every active marker at once, for example when resetting between E2E scenarios. Clearing also removes the underlying code patch, so the method runs untouched afterwards.

## Capture Modes and History

Choose the capture mode when enabling a pause point:

- `single-shot` is the default. The first hit pauses Unity and disarms the marker.
- `continuous` pauses Unity on every hit and remains armed. Each hit adds a frame to `CapturedVariableHistory`, while `CapturedVariables` remains the latest-hit compatibility view.
- `trace` remains armed and records each hit without pausing Unity.

`--max-history` defaults to 20 and accepts values from 1 through 100. When the limit is exceeded, the oldest frames are dropped and `HistoryDroppedCount` reports how many were removed. `pause-point-status` returns the current `Mode`, `MaxHistory`, history frames, and dropped count.

To inspect value changes one Editor Step at a time, enable a `continuous` pause point on a line inside `Update` or `FixedUpdate`, trigger the first hit, then run:

```bash
uloop control-play-mode --action Step
uloop pause-point-status --id "Assets/Scripts/Enemy.cs:42"
```

Repeat the Step/status pair to inspect the history tail. A new frame is captured only when the patched line executes during that frame; event handlers such as `OnCollisionEnter` update only when the event occurs again. Use a longer `--timeout-seconds` for a Step session because the enable-time timeout does not extend after hits.

## Reading CapturedVariables

Every hit response embeds `CapturedVariables`: the method's in-scope locals, its parameters, and the `this` instance fields, captured at the exact moment execution reached the patched line. Values are point-in-time strings, not live references, so they stay valid as evidence even after Unity resumes.
Expand Down
185 changes: 185 additions & 0 deletions Assets/Tests/Editor/PausePointCaptureModeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
using System;
using System.Linq;

using NUnit.Framework;

using io.github.hatayama.UnityCliLoop.Runtime;

namespace io.github.hatayama.UnityCliLoop.Tests.Editor
{
/// <summary>
/// Verifies pause point capture modes and bounded hit history behavior.
/// </summary>
[TestFixture]
public sealed class PausePointCaptureModeTests
{
private DateTime _nowUtc;
private FakePausePointPauseController _pauseController;

[SetUp]
public void SetUp()
{
_nowUtc = new DateTime(2026, 6, 3, 0, 0, 0, DateTimeKind.Utc);
_pauseController = new FakePausePointPauseController();
UloopPausePointRegistry.ConfigureForTests(_pauseController, () => _nowUtc);
}

[TearDown]
public void TearDown()
{
UloopPausePointRegistry.ResetForTests();
}

/// <summary>
/// Verifies continuous capture keeps the marker armed while exposing ordered history and the latest variables.
/// </summary>
[Test]
public void Continuous_WhenHitMultipleTimes_PreservesHistoryAndLatestVariables()
{
UloopCapturedVariable[] firstVariables = { CreateVariable("speed", "1") };
UloopCapturedVariable[] secondVariables = { CreateVariable("speed", "2") };
UloopPausePointRegistry.Enable("jump", 30, UloopPausePointCaptureMode.Continuous, 20);

UloopPausePointRegistry.HitWithCapturedVariables("jump", firstVariables, false);
UloopPausePointRegistry.HitWithCapturedVariables("jump", secondVariables, false);

UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump");

Assert.That(snapshot.IsEnabled, Is.True);
Assert.That(snapshot.HitCount, Is.EqualTo(2));
Assert.That(snapshot.CapturedVariables.Single().Value, Is.EqualTo("2"));
Assert.That(snapshot.CapturedVariableHistory.Select(frame => frame.HitSequence), Is.EqualTo(new[] { 1, 2 }));
Assert.That(snapshot.CapturedVariableHistory.Last().CapturedVariables.Single().Value, Is.EqualTo("2"));
Assert.That(_pauseController.PauseCount, Is.EqualTo(2));
}

/// <summary>
/// Verifies trace capture records a hit without requesting an Editor pause.
/// </summary>
[Test]
public void Trace_WhenHit_DoesNotPauseAndKeepsMarkerArmed()
{
UloopPausePointRegistry.Enable("trace", 30, UloopPausePointCaptureMode.Trace, 20);

UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Hit("trace");

Assert.That(snapshot.IsEnabled, Is.True);
Assert.That(snapshot.HitCount, Is.EqualTo(1));
Assert.That(snapshot.Mode, Is.EqualTo(UloopPausePointCaptureMode.Trace));
Assert.That(snapshot.CapturedVariableHistory, Has.Count.EqualTo(1));
Assert.That(_pauseController.PauseCount, Is.EqualTo(0));
}

/// <summary>
/// Verifies the bounded history drops the oldest frame and reports the dropped count.
/// </summary>
[Test]
public void History_WhenMaxHistoryIsExceeded_DropsOldestFrames()
{
UloopPausePointRegistry.Enable("jump", 30, UloopPausePointCaptureMode.Trace, 2);

UloopPausePointRegistry.Hit("jump");
UloopPausePointRegistry.Hit("jump");
UloopPausePointRegistry.Hit("jump");

UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump");

Assert.That(snapshot.CapturedVariableHistory.Select(frame => frame.HitSequence), Is.EqualTo(new[] { 2, 3 }));
Assert.That(snapshot.HistoryDroppedCount, Is.EqualTo(1));
}

/// <summary>
/// Verifies the default single-shot mode retains current disarm behavior and records its hit.
/// </summary>
[Test]
public void SingleShot_WhenHit_DisarmsAndKeepsOneHistoryFrame()
{
UloopPausePointRegistry.Enable("jump", 30);

UloopPausePointRegistry.Hit("jump");
UloopPausePointRegistry.Hit("jump");

UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump");

Assert.That(snapshot.IsEnabled, Is.False);
Assert.That(snapshot.HitCount, Is.EqualTo(1));
Assert.That(snapshot.CapturedVariableHistory, Has.Count.EqualTo(1));
Assert.That(_pauseController.PauseCount, Is.EqualTo(1));
}

/// <summary>
/// Verifies clearing an armed continuous marker disarms it without erasing captured history.
/// </summary>
[Test]
public void Clear_WhenContinuousMarkerHasHits_DisarmsAndPreservesHistory()
{
UloopPausePointRegistry.Enable("jump", 30, UloopPausePointCaptureMode.Continuous, 20);
UloopPausePointRegistry.Hit("jump");

UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Clear("jump");

Assert.That(snapshot.IsEnabled, Is.False);
Assert.That(snapshot.CapturedVariableHistory, Has.Count.EqualTo(1));
Assert.That(snapshot.ClearedReason, Is.EqualTo(UloopPausePointClearedReason.ExplicitClear));
Assert.That(snapshot.Message, Is.EqualTo("Pause point cleared after 1 hit(s); capture history is preserved."));
}

/// <summary>
/// Verifies expiry after a continuous hit reports a capture-window message and retains history.
/// </summary>
[Test]
public void Expire_WhenContinuousMarkerHasHits_DisarmsAndPreservesHistory()
{
UloopPausePointRegistry.Enable("jump", 1, UloopPausePointCaptureMode.Continuous, 20);
UloopPausePointRegistry.Hit("jump");
_nowUtc = _nowUtc.AddSeconds(2);

UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump");

Assert.That(snapshot.Status, Is.EqualTo(UloopPausePointStatus.Expired));
Assert.That(snapshot.CapturedVariableHistory, Has.Count.EqualTo(1));
Assert.That(snapshot.Message, Is.EqualTo("Pause point capture window expired after 1 hit(s); capture history is preserved."));
}

/// <summary>
/// Verifies re-enabling a marker starts a fresh generation with empty history.
/// </summary>
[Test]
public void Enable_WhenMarkerIsReenabled_ResetsHistory()
{
UloopPausePointRegistry.Enable("jump", 30, UloopPausePointCaptureMode.Trace, 2);
UloopPausePointRegistry.Hit("jump");

UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Enable(
"jump", 30, UloopPausePointCaptureMode.Trace, 2);

Assert.That(snapshot.HitCount, Is.EqualTo(0));
Assert.That(snapshot.CapturedVariableHistory, Is.Empty);
Assert.That(snapshot.HistoryDroppedCount, Is.EqualTo(0));
}

private static UloopCapturedVariable CreateVariable(string name, string value)
{
return new UloopCapturedVariable(
name,
UloopCapturedVariableScope.Local,
"System.String",
value,
string.Empty,
string.Empty,
0);
}

private sealed class FakePausePointPauseController : IUloopPausePointPauseController
{
public int PauseCount { get; private set; }
public bool IsPlaying => true;
public bool IsPaused => PauseCount > 0;

public void Pause()
{
PauseCount++;
}
}
}
}
11 changes: 11 additions & 0 deletions Assets/Tests/Editor/PausePointCaptureModeTests.cs.meta

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

14 changes: 14 additions & 0 deletions Assets/Tests/Editor/PausePointStatusResponseContractTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,20 @@ public void PausePointStatusResponse_WhenSerialized_MatchesSharedContractFieldSh
IsHit = true,
HitCount = 1,
TimeoutSeconds = 30,
Mode = "continuous",
MaxHistory = 20,
CapturedVariableHistory = new List<PausePointStatusCapturedHistoryFrame>
{
new()
{
HitSequence = 1,
FrameCount = 42,
HitAtUtc = "2026-06-03T00:00:01.0000000Z",
CapturedVariables = new List<PausePointStatusCapturedVariable>(),
Truncated = false
}
},
HistoryDroppedCount = 0,
Expired = false,
EnabledAtUtc = "2026-06-03T00:00:00.0000000Z",
ElapsedSinceEnabledMilliseconds = 1200,
Expand Down
Loading
Loading