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
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 Packages/src/Runtime/PausePoints/UloopPausePointCaptureMode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#if UNITY_EDITOR
namespace io.github.hatayama.UnityCliLoop.Runtime
{
/// <summary>
/// Defines how a pause point behaves after it captures a hit.
/// </summary>
internal static class UloopPausePointCaptureMode
{
public const string SingleShot = "single-shot";
public const string Continuous = "continuous";
public const string Trace = "trace";
}
}
#endif

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

Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#if UNITY_EDITOR
using System.Collections.Generic;

namespace io.github.hatayama.UnityCliLoop.Runtime
{
/// <summary>
/// Stores the formatted evidence captured by one pause point hit.
/// </summary>
internal sealed class UloopPausePointCapturedHistoryFrame
{
public UloopPausePointCapturedHistoryFrame(
int hitSequence,
int frameCount,
string hitAtUtc,
IReadOnlyList<UloopCapturedVariable> capturedVariables,
bool truncated)
{
HitSequence = hitSequence;
FrameCount = frameCount;
HitAtUtc = hitAtUtc;
CapturedVariables = capturedVariables;
Truncated = truncated;
}

public int HitSequence { get; }
public int FrameCount { get; }
public string HitAtUtc { get; }
public IReadOnlyList<UloopCapturedVariable> CapturedVariables { get; }
public bool Truncated { get; }
}
}
#endif

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

52 changes: 42 additions & 10 deletions Packages/src/Runtime/PausePoints/UloopPausePointEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,32 @@ namespace io.github.hatayama.UnityCliLoop.Runtime
/// </summary>
internal sealed class UloopPausePointEntry
{
public UloopPausePointEntry(string id, int timeoutSeconds, DateTime enabledAtUtc, int generation)
public UloopPausePointEntry(
string id,
int timeoutSeconds,
string mode,
int maxHistory,
DateTime enabledAtUtc,
int generation)
{
Id = id;
TimeoutSeconds = timeoutSeconds;
Mode = mode;
MaxHistory = maxHistory;
EnabledAtUtc = enabledAtUtc;
ExpiresAtUtc = enabledAtUtc.AddSeconds(timeoutSeconds);
Generation = generation;
Status = UloopPausePointStatus.Enabled;
IsEnabled = true;
Message = "Pause point enabled.";
CapturedVariables = Array.Empty<UloopCapturedVariable>();
_capturedVariableHistory = new Queue<UloopPausePointCapturedHistoryFrame>(maxHistory);
}

public string Id { get; }
public int TimeoutSeconds { get; }
public string Mode { get; }
public int MaxHistory { get; }
public DateTime EnabledAtUtc { get; }
public DateTime ExpiresAtUtc { get; }
public int Generation { get; }
Expand All @@ -40,6 +51,7 @@ public UloopPausePointEntry(string id, int timeoutSeconds, DateTime enabledAtUtc
public string Message { get; private set; }
public IReadOnlyList<UloopCapturedVariable> CapturedVariables { get; private set; }
public bool CapturedVariablesTruncated { get; private set; }
public int HistoryDroppedCount { get; private set; }
public string ClearedReason { get; private set; } = string.Empty;
public string StatusBeforeClear { get; private set; } = string.Empty;
public bool LateHitDiscardedAfterClear { get; private set; }
Expand All @@ -58,7 +70,9 @@ public void ExpireIfNeeded(DateTime nowUtc)

IsEnabled = false;
Status = UloopPausePointStatus.Expired;
Message = "Pause point expired before it was hit.";
Message = HitCount == 0
? "Pause point expired before it was hit."
: $"Pause point capture window expired after {HitCount} hit(s); capture history is preserved.";
}

public void MarkCleared(string clearedReason, string message = "Pause point cleared.")
Expand All @@ -78,6 +92,7 @@ public void MarkCleared(string clearedReason, string message = "Pause point clea
ClearedReason = UloopPausePointClearedReason.AfterExpired;
}
else if (Status == UloopPausePointStatus.Hit &&
!IsEnabled &&
clearedReason == UloopPausePointClearedReason.ExplicitClear)
{
ClearedReason = UloopPausePointClearedReason.AlreadyHit;
Expand All @@ -99,17 +114,12 @@ public void MarkLateHitDiscardedAfterClear()
LateHitDiscardedAfterClear = true;
}

public void RecordHit(DateTime nowUtc, bool isPlaying, bool isPaused, int hitSequence)
{
RecordHitWithCapturedVariables(
nowUtc, isPlaying, isPaused, hitSequence, Array.Empty<UloopCapturedVariable>(), false);
}

public void RecordHitWithCapturedVariables(
DateTime nowUtc,
bool isPlaying,
bool isPaused,
int hitSequence,
int frameCount,
IReadOnlyList<UloopCapturedVariable> capturedVariables,
bool capturedVariablesTruncated)
{
Expand All @@ -127,11 +137,27 @@ public void RecordHitWithCapturedVariables(
LastHitSequence = hitSequence;
IsPlayingAtHit = isPlaying;
IsPausedAtHit = isPaused;
IsEnabled = false;
IsEnabled = Mode != UloopPausePointCaptureMode.SingleShot;
Status = UloopPausePointStatus.Hit;
Message = "Pause point hit; Unity pause was requested.";
Message = Mode == UloopPausePointCaptureMode.Trace
? "Pause point hit; recorded to history without pausing (trace mode)."
: "Pause point hit; Unity pause was requested.";
CapturedVariables = capturedVariables;
CapturedVariablesTruncated = capturedVariablesTruncated;

if (_capturedVariableHistory.Count == MaxHistory)
{
_capturedVariableHistory.Dequeue();
HistoryDroppedCount++;
}

_capturedVariableHistory.Enqueue(
new UloopPausePointCapturedHistoryFrame(
hitSequence,
frameCount,
FormatUtc(nowUtc),
capturedVariables,
capturedVariablesTruncated));
}

public UloopPausePointSnapshot ToSnapshot(DateTime nowUtc, IUloopPausePointPauseController pauseController)
Expand Down Expand Up @@ -161,6 +187,10 @@ public UloopPausePointSnapshot ToSnapshot(DateTime nowUtc, IUloopPausePointPause
isHit,
HitCount,
TimeoutSeconds,
Mode,
MaxHistory,
new List<UloopPausePointCapturedHistoryFrame>(_capturedVariableHistory),
HistoryDroppedCount,
expired,
FormatUtc(EnabledAtUtc),
elapsedMilliseconds,
Expand Down Expand Up @@ -201,6 +231,8 @@ private static string FormatUtc(DateTime value)
DateTime utcValue = value.Kind == DateTimeKind.Utc ? value : value.ToUniversalTime();
return utcValue.ToString("O");
}

private readonly Queue<UloopPausePointCapturedHistoryFrame> _capturedVariableHistory;
}
}
#endif
Loading