From 17f882a4f78021ca16e06bf5c73d833977162eba Mon Sep 17 00:00:00 2001 From: hatayama <842587+hatayama@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:42:04 +0900 Subject: [PATCH] Expose pause point lifecycle state Add explicit expiry, timing, generation, and recovery fields to pause point responses so agents can distinguish stale markers from fresh hits and re-arm expired markers without recomputing state from partial details. --- Assets/Tests/Editor/PausePointTests.cs | 74 +++++++- .../CliOnlyTools~/PausePoint/Skill/SKILL.md | 4 +- .../PausePoint/PausePointTools.cs | 12 +- .../Api/PausePointStatusBridgeCommand.cs | 12 +- .../PausePoints/UloopPausePointRegistry.cs | 65 ++++++- cli/internal/cli/pause_point_errors.go | 23 ++- cli/internal/cli/pause_point_wait.go | 31 +++ cli/internal/cli/pause_point_wait_test.go | 177 +++++++++++++++++- 8 files changed, 386 insertions(+), 12 deletions(-) diff --git a/Assets/Tests/Editor/PausePointTests.cs b/Assets/Tests/Editor/PausePointTests.cs index 019e77d528..5eb00bc7e3 100644 --- a/Assets/Tests/Editor/PausePointTests.cs +++ b/Assets/Tests/Editor/PausePointTests.cs @@ -8,6 +8,7 @@ using UnityEditor; using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.Infrastructure; using io.github.hatayama.UnityCliLoop.Runtime; namespace io.github.hatayama.UnityCliLoop.Tests.Editor @@ -124,20 +125,54 @@ public void GetStatus_WhenTimeoutPasses_ExpiresAndDisarms() UloopPausePoint.Pause("jump"); Assert.That(snapshot.Status, Is.EqualTo(UloopPausePointStatus.Expired)); + Assert.That(snapshot.Expired, Is.True); + Assert.That(snapshot.RemainingMilliseconds, Is.EqualTo(0)); + Assert.That( + snapshot.RecommendedNextAction, + Is.EqualTo("Clear this marker, then re-enable it with the same Id and TimeoutSeconds values.")); Assert.That(snapshot.IsEnabled, Is.False); Assert.That(_pauseController.PauseCount, Is.EqualTo(0)); } [Test] - public void GetStatus_WhenEnabled_ReportsElapsedSinceEnabledMilliseconds() + public void GetStatus_WhenExpiredIdContainsShellSyntax_ReturnsShellNeutralRecoveryAction() { - // Verifies elapsed time is named as time since the marker was enabled. + // Verifies recovery guidance does not embed shell syntax that differs between user environments. + UloopPausePointRegistry.Enable("jump && other-command", 1); + _nowUtc = _nowUtc.AddSeconds(2); + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump && other-command"); + + Assert.That( + snapshot.RecommendedNextAction, + Is.EqualTo("Clear this marker, then re-enable it with the same Id and TimeoutSeconds values.")); + } + + [Test] + public void GetStatus_WhenEnabled_ReportsTimingAndGenerationFields() + { + // Verifies status reports the marker lifetime and generation without making callers recompute it. UloopPausePointRegistry.Enable("jump", 30); _nowUtc = _nowUtc.AddMilliseconds(250); UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); + Assert.That(snapshot.EnabledAtUtc, Is.EqualTo("2026-06-03T00:00:00.0000000Z")); Assert.That(snapshot.ElapsedSinceEnabledMilliseconds, Is.EqualTo(250)); + Assert.That(snapshot.RemainingMilliseconds, Is.EqualTo(29750)); + Assert.That(snapshot.Generation, Is.EqualTo(1)); + Assert.That(snapshot.Expired, Is.False); + } + + [Test] + public void Enable_WhenMarkerIsReenabled_IncrementsGeneration() + { + // Verifies callers can distinguish a fresh marker from stale status or log evidence with the same id. + UloopPausePointSnapshot firstSnapshot = UloopPausePointRegistry.Enable("jump", 30); + UloopPausePointSnapshot secondSnapshot = UloopPausePointRegistry.Enable("jump", 30); + + Assert.That(firstSnapshot.Generation, Is.EqualTo(1)); + Assert.That(secondSnapshot.Generation, Is.EqualTo(2)); } [Test] @@ -206,6 +241,41 @@ public async Task ClearAll_WhenNothingActive_ReportsNoActiveMessage() Assert.That(response.Message, Is.EqualTo("No active pause points to clear.")); } + [Test] + public async Task Enable_WhenMarkerCreated_ReturnsStateManagementFields() + { + // Verifies the public enable-pause-point tool exposes timing and generation fields from the registry. + PausePointResponse response = await EnablePausePointAsync("jump"); + + Assert.That(response.EnabledAtUtc, Is.EqualTo("2026-06-03T00:00:00.0000000Z")); + Assert.That(response.RemainingMilliseconds, Is.EqualTo(30000)); + Assert.That(response.Generation, Is.EqualTo(1)); + Assert.That(response.Expired, Is.False); + Assert.That(response.RecommendedNextAction, Is.Empty); + } + + [Test] + public void PausePointStatusBridge_WhenMarkerExpired_ReturnsRecoveryAction() + { + // Verifies pause-point-status exposes enough data to re-arm an expired marker without guesswork. + UloopPausePointRegistry.Enable("jump", 1); + _nowUtc = _nowUtc.AddSeconds(2); + JObject parameters = new() + { + ["id"] = "jump" + }; + + PausePointStatusResponse response = PausePointStatusBridgeCommand.Execute(parameters); + + Assert.That(response.Expired, Is.True); + Assert.That(response.EnabledAtUtc, Is.EqualTo("2026-06-03T00:00:00.0000000Z")); + Assert.That(response.RemainingMilliseconds, Is.EqualTo(0)); + Assert.That(response.Generation, Is.EqualTo(1)); + Assert.That( + response.RecommendedNextAction, + Is.EqualTo("Clear this marker, then re-enable it with the same Id and TimeoutSeconds values.")); + } + [Test] public void Enable_WhenSamePausePointWasHit_ClearsLatestHitSnapshot() { diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index 74981cf37b..5b3baa2fd1 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -32,6 +32,8 @@ uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30 The hit response always embeds the log entries matching the marker id as `MatchingLogs` (`--matching-logs-max-count` adjusts the limit, default 10), so a separate `get-logs` call while paused is unnecessary. Log embedding is always on; there is no opt-in flag, and a `--include-matching-logs` option no longer exists. An empty `MatchingLogs` array means the fetch succeeded and no matching log exists; if the field is absent, the log fetch itself failed, so fall back to `uloop get-logs --search-text state-transition-applied --max-count 20` while paused. +Use `Generation` and `EnabledAtUtc` from the hit or status response to tell a fresh marker from stale evidence with the same id. `RemainingMilliseconds` and `Expired` are returned directly so you do not need to infer marker lifetime from elapsed time. + 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 state-transition-applied` 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. @@ -48,7 +50,7 @@ The hit response always embeds the log entries matching the marker id as `Matchi ## Timeout Checks -If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so re-enable the marker with a longer timeout. Then inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, runtime conditions not being met, an id mismatch, or Unity already being paused. `error.details.MatchingLogs` shows whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. +If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint and shell-neutral `error.details.recommendedNextAction`: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so clear and re-enable the marker using the returned `id` and `timeoutSeconds`. Then inspect `error.details.status`, `hitCount`, `generation`, `enabledAtUtc`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, stale evidence from an older marker generation, runtime conditions not being met, an id mismatch, or Unity already being paused. `error.details.MatchingLogs` shows whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. Use `uloop pause-point-status --id state-transition-applied` only when you need to confirm the marker is armed or inspect the current hit state. Add focused debug logs before the marker when local variables must be captured. diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs index 4409f1c3e1..19a894e1b1 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs @@ -39,11 +39,16 @@ public class PausePointResponse : UnityCliLoopToolResponse public bool IsHit { get; set; } public int HitCount { get; set; } public int TimeoutSeconds { get; set; } + public bool Expired { get; set; } + public string EnabledAtUtc { get; set; } = string.Empty; public long ElapsedSinceEnabledMilliseconds { get; set; } + public long RemainingMilliseconds { get; set; } + public int Generation { get; set; } public bool IsPlaying { get; set; } public bool IsPaused { get; set; } public int ClearedCount { get; set; } public string Message { get; set; } = string.Empty; + public string RecommendedNextAction { get; set; } = string.Empty; public string Warning { get; set; } = string.Empty; internal static PausePointResponse FromSnapshot(UloopPausePointSnapshot snapshot) @@ -61,10 +66,15 @@ internal static PausePointResponse FromSnapshot(UloopPausePointSnapshot snapshot IsHit = snapshot.IsHit, HitCount = snapshot.HitCount, TimeoutSeconds = snapshot.TimeoutSeconds, + Expired = snapshot.Expired, + EnabledAtUtc = snapshot.EnabledAtUtc, ElapsedSinceEnabledMilliseconds = snapshot.ElapsedSinceEnabledMilliseconds, + RemainingMilliseconds = snapshot.RemainingMilliseconds, + Generation = snapshot.Generation, IsPlaying = snapshot.IsPlaying, IsPaused = snapshot.IsPaused, - Message = snapshot.Message + Message = snapshot.Message, + RecommendedNextAction = snapshot.RecommendedNextAction }; } diff --git a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs index 52d7d16bfa..cd88855c79 100644 --- a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs +++ b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs @@ -50,10 +50,15 @@ public class PausePointStatusResponse : UnityCliLoopToolResponse public bool IsHit { get; set; } public int HitCount { get; set; } public int TimeoutSeconds { get; set; } + public bool Expired { get; set; } + public string EnabledAtUtc { get; set; } = string.Empty; public long ElapsedSinceEnabledMilliseconds { get; set; } + public long RemainingMilliseconds { get; set; } + public int Generation { get; set; } public bool IsPlaying { get; set; } public bool IsPaused { get; set; } public string Message { get; set; } = string.Empty; + public string RecommendedNextAction { get; set; } = string.Empty; internal static PausePointStatusResponse FromSnapshot(UloopPausePointSnapshot snapshot) { @@ -70,10 +75,15 @@ internal static PausePointStatusResponse FromSnapshot(UloopPausePointSnapshot sn IsHit = snapshot.IsHit, HitCount = snapshot.HitCount, TimeoutSeconds = snapshot.TimeoutSeconds, + Expired = snapshot.Expired, + EnabledAtUtc = snapshot.EnabledAtUtc, ElapsedSinceEnabledMilliseconds = snapshot.ElapsedSinceEnabledMilliseconds, + RemainingMilliseconds = snapshot.RemainingMilliseconds, + Generation = snapshot.Generation, IsPlaying = snapshot.IsPlaying, IsPaused = snapshot.IsPaused, - Message = snapshot.Message + Message = snapshot.Message, + RecommendedNextAction = snapshot.RecommendedNextAction }; } } diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs index 72c78f9c24..c955a85b85 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs @@ -16,6 +16,7 @@ internal static class UloopPausePointRegistry private static readonly Dictionary Entries = new(); private static IUloopPausePointPauseController _pauseController = new UnityEditorPausePointPauseController(); private static Func _nowProvider = () => DateTime.UtcNow; + private static int _nextGeneration; private static UloopPausePointSnapshot _latestHitSnapshot; // One input can hit several markers in the same frame; tools need the full list, // not just the latest hit, to report every marker that interrupted them. @@ -27,7 +28,8 @@ public static UloopPausePointSnapshot Enable(string id, int timeoutSeconds) Debug.Assert(timeoutSeconds > 0, "timeoutSeconds must be greater than zero"); DateTime now = NowUtc(); - UloopPausePointEntry entry = new(id, timeoutSeconds, now); + int generation = ++_nextGeneration; + UloopPausePointEntry entry = new(id, timeoutSeconds, now, generation); Entries[id] = entry; ClearLatestHitSnapshotIfMatches(id); return entry.ToSnapshot(now, _pauseController); @@ -166,6 +168,7 @@ public static void ConfigureForTests(IUloopPausePointPauseController pauseContro public static void ResetForTests() { Entries.Clear(); + _nextGeneration = 0; _latestHitSnapshot = null; _hitSnapshots.Clear(); _pauseController = new UnityEditorPausePointPauseController(); @@ -215,10 +218,15 @@ public UloopPausePointSnapshot( bool isHit, int hitCount, int timeoutSeconds, + bool expired, + string enabledAtUtc, long elapsedMilliseconds, + long remainingMilliseconds, + int generation, bool isPlaying, bool isPaused, - string message) + string message, + string recommendedNextAction) { Id = id ?? string.Empty; Status = status ?? UloopPausePointStatus.NotEnabled; @@ -226,10 +234,15 @@ public UloopPausePointSnapshot( IsHit = isHit; HitCount = hitCount; TimeoutSeconds = timeoutSeconds; + Expired = expired; + EnabledAtUtc = enabledAtUtc ?? string.Empty; ElapsedSinceEnabledMilliseconds = elapsedMilliseconds; + RemainingMilliseconds = remainingMilliseconds; + Generation = generation; IsPlaying = isPlaying; IsPaused = isPaused; Message = message ?? string.Empty; + RecommendedNextAction = recommendedNextAction ?? string.Empty; } public string Id { get; } @@ -238,10 +251,15 @@ public UloopPausePointSnapshot( public bool IsHit { get; } public int HitCount { get; } public int TimeoutSeconds { get; } + public bool Expired { get; } + public string EnabledAtUtc { get; } public long ElapsedSinceEnabledMilliseconds { get; } + public long RemainingMilliseconds { get; } + public int Generation { get; } public bool IsPlaying { get; } public bool IsPaused { get; } public string Message { get; } + public string RecommendedNextAction { get; } public static UloopPausePointSnapshot NotEnabled(string id, IUloopPausePointPauseController pauseController) { @@ -254,10 +272,15 @@ public static UloopPausePointSnapshot NotEnabled(string id, IUloopPausePointPaus false, 0, 0, + false, + string.Empty, + 0, + 0, 0, pauseController.IsPlaying, pauseController.IsPaused, - "Pause point is not enabled."); + "Pause point is not enabled.", + string.Empty); } } @@ -281,12 +304,13 @@ public UloopPausePointClearAllResult(int clearedCount, DateTime clearedAtUtc) /// internal sealed class UloopPausePointEntry { - public UloopPausePointEntry(string id, int timeoutSeconds, DateTime enabledAtUtc) + public UloopPausePointEntry(string id, int timeoutSeconds, DateTime enabledAtUtc, int generation) { Id = id; TimeoutSeconds = timeoutSeconds; EnabledAtUtc = enabledAtUtc; ExpiresAtUtc = enabledAtUtc.AddSeconds(timeoutSeconds); + Generation = generation; Status = UloopPausePointStatus.Enabled; IsEnabled = true; Message = "Pause point enabled."; @@ -296,6 +320,7 @@ public UloopPausePointEntry(string id, int timeoutSeconds, DateTime enabledAtUtc public int TimeoutSeconds { get; } public DateTime EnabledAtUtc { get; } public DateTime ExpiresAtUtc { get; } + public int Generation { get; } public string Status { get; private set; } public bool IsEnabled { get; private set; } public int HitCount { get; private set; } @@ -344,9 +369,12 @@ public UloopPausePointSnapshot ToSnapshot(DateTime nowUtc, IUloopPausePointPause Debug.Assert(pauseController != null, "pauseController must not be null"); bool isHit = Status == UloopPausePointStatus.Hit; + bool expired = Status == UloopPausePointStatus.Expired; bool isPlaying = isHit ? IsPlayingAtHit : pauseController.IsPlaying; bool isPaused = isHit ? IsPausedAtHit : pauseController.IsPaused; long elapsedMilliseconds = Math.Max(0, (long)(nowUtc - EnabledAtUtc).TotalMilliseconds); + long remainingMilliseconds = CalculateRemainingMilliseconds(nowUtc); + string recommendedNextAction = expired ? CreateExpiredRecommendedNextAction() : string.Empty; return new UloopPausePointSnapshot( Id, @@ -355,10 +383,37 @@ public UloopPausePointSnapshot ToSnapshot(DateTime nowUtc, IUloopPausePointPause isHit, HitCount, TimeoutSeconds, + expired, + FormatUtc(EnabledAtUtc), elapsedMilliseconds, + remainingMilliseconds, + Generation, isPlaying, isPaused, - Message); + Message, + recommendedNextAction); + } + + private long CalculateRemainingMilliseconds(DateTime nowUtc) + { + if (!IsEnabled) + { + return 0; + } + + long remainingMilliseconds = (long)(ExpiresAtUtc - nowUtc).TotalMilliseconds; + return Math.Max(0, remainingMilliseconds); + } + + private string CreateExpiredRecommendedNextAction() + { + return "Clear this marker, then re-enable it with the same Id and TimeoutSeconds values."; + } + + private static string FormatUtc(DateTime value) + { + DateTime utcValue = value.Kind == DateTimeKind.Utc ? value : value.ToUniversalTime(); + return utcValue.ToString("O"); } } diff --git a/cli/internal/cli/pause_point_errors.go b/cli/internal/cli/pause_point_errors.go index 8c6a19edc7..bdf6f6c21f 100644 --- a/cli/internal/cli/pause_point_errors.go +++ b/cli/internal/cli/pause_point_errors.go @@ -11,6 +11,8 @@ func pausePointWaitError( response pausePointStatusResponse, state pausePointWaitState, ) cliError { + response = normalizePausePointStatusResponse(response) + switch state { case pausePointWaitStateNotEnabled: return pausePointStateError( @@ -118,18 +120,37 @@ func pausePointStateError( Details: map[string]any{ "id": options.id, "status": response.Status, + "expired": response.Expired, "hitCount": response.HitCount, - "timeoutSeconds": options.timeoutSeconds, + "timeoutSeconds": pausePointMarkerTimeoutSeconds(options, response), + "enabledAtUtc": response.EnabledAtUtc, "elapsedSinceEnabledMilliseconds": response.ElapsedSinceEnabledMilliseconds, + "generation": response.Generation, "isPlaying": response.IsPlaying, "isPaused": response.IsPaused, "remainingMilliseconds": pausePointRemainingMilliseconds(options, response), "markerMessage": response.Message, + "recommendedNextAction": response.RecommendedNextAction, }, } } +func pausePointMarkerTimeoutSeconds(options waitForPausePointOptions, response pausePointStatusResponse) int { + if response.TimeoutSeconds > 0 { + return response.TimeoutSeconds + } + return options.timeoutSeconds +} + func pausePointRemainingMilliseconds(options waitForPausePointOptions, response pausePointStatusResponse) int64 { + if response.RemainingMilliseconds > 0 { + return response.RemainingMilliseconds + } + if response.Expired || response.Status == pausePointStatusExpired || response.Status == pausePointStatusHit || + response.Status == pausePointStatusCleared { + return 0 + } + timeoutSeconds := response.TimeoutSeconds if timeoutSeconds <= 0 { return 0 diff --git a/cli/internal/cli/pause_point_wait.go b/cli/internal/cli/pause_point_wait.go index 0c5b57159a..97b6775cf9 100644 --- a/cli/internal/cli/pause_point_wait.go +++ b/cli/internal/cli/pause_point_wait.go @@ -52,10 +52,15 @@ type pausePointStatusResponse struct { IsHit bool `json:"IsHit"` HitCount int `json:"HitCount"` TimeoutSeconds int `json:"TimeoutSeconds"` + Expired bool `json:"Expired"` + EnabledAtUtc string `json:"EnabledAtUtc"` ElapsedSinceEnabledMilliseconds int64 `json:"ElapsedSinceEnabledMilliseconds"` + RemainingMilliseconds int64 `json:"RemainingMilliseconds"` + Generation int `json:"Generation"` IsPlaying bool `json:"IsPlaying"` IsPaused bool `json:"IsPaused"` Message string `json:"Message"` + RecommendedNextAction string `json:"RecommendedNextAction"` } type pausePointWaitState string @@ -68,6 +73,31 @@ const ( pausePointWaitStateCleared pausePointWaitState = "cleared" ) +func normalizePausePointStatusResponse(response pausePointStatusResponse) pausePointStatusResponse { + if response.Status == pausePointStatusExpired { + response.Expired = true + response.RemainingMilliseconds = 0 + return response + } + + if response.Status != pausePointStatusEnabled || response.RemainingMilliseconds > 0 { + return response + } + + if response.TimeoutSeconds <= 0 { + return response + } + + totalMilliseconds := int64(response.TimeoutSeconds) * int64(time.Second/time.Millisecond) + remainingMilliseconds := totalMilliseconds - response.ElapsedSinceEnabledMilliseconds + if remainingMilliseconds <= 0 { + return response + } + + response.RemainingMilliseconds = remainingMilliseconds + return response +} + func runWaitForPausePointCommand( ctx context.Context, connection unityipc.Connection, @@ -111,6 +141,7 @@ func runPausePointStatusCommand( }) return 1 } + response = normalizePausePointStatusResponse(response) result, err := json.Marshal(response) if err != nil { diff --git a/cli/internal/cli/pause_point_wait_test.go b/cli/internal/cli/pause_point_wait_test.go index dd08f8733b..9c59fc5c54 100644 --- a/cli/internal/cli/pause_point_wait_test.go +++ b/cli/internal/cli/pause_point_wait_test.go @@ -133,6 +133,80 @@ func TestRunWaitForPausePointClearsEnabledMarkerAfterTimeout(t *testing.T) { } } +// Verifies pause point wait errors expose recovery and generation details from Unity. +func TestPausePointExpiredErrorReportsRecoveryFields(t *testing.T) { + response := pausePointStatusResponse{ + Id: "jump", + Status: pausePointStatusExpired, + Expired: true, + TimeoutSeconds: 1, + EnabledAtUtc: "2026-06-03T00:00:00.0000000Z", + ElapsedSinceEnabledMilliseconds: 1200, + RemainingMilliseconds: 0, + Generation: 7, + IsPlaying: true, + Message: "Pause point expired before it was hit.", + RecommendedNextAction: "Clear this marker, then re-enable it with the same Id and TimeoutSeconds values.", + } + + cliErr := pausePointWaitError("/tmp/MyProject", waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 1, + }, response, pausePointWaitStateExpired) + + if cliErr.Details["expired"] != true { + t.Fatalf("expired detail mismatch: %#v", cliErr.Details) + } + if cliErr.Details["enabledAtUtc"] != "2026-06-03T00:00:00.0000000Z" { + t.Fatalf("enabledAtUtc detail mismatch: %#v", cliErr.Details) + } + if cliErr.Details["generation"] != 7 { + t.Fatalf("generation detail mismatch: %#v", cliErr.Details) + } + if cliErr.Details["recommendedNextAction"] != response.RecommendedNextAction { + t.Fatalf("recommendedNextAction detail mismatch: %#v", cliErr.Details) + } +} + +// Verifies recovery details use the marker lifetime instead of the wait deadline. +func TestPausePointExpiredErrorReportsMarkerTimeoutSeconds(t *testing.T) { + response := pausePointStatusResponse{ + Id: "jump", + Status: pausePointStatusExpired, + TimeoutSeconds: 30, + IsPlaying: true, + Message: "Pause point expired before it was hit.", + } + + cliErr := pausePointWaitError("/tmp/MyProject", waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 5, + }, response, pausePointWaitStateExpired) + + if cliErr.Details["timeoutSeconds"] != 30 { + t.Fatalf("timeoutSeconds detail mismatch: %#v", cliErr.Details) + } +} + +// Verifies wait errors derive Expired from Status when older Unity packages omit the bool field. +func TestPausePointExpiredErrorDerivesExpiredFromStatus(t *testing.T) { + response := pausePointStatusResponse{ + Id: "jump", + Status: pausePointStatusExpired, + IsPlaying: true, + Message: "Pause point expired before it was hit.", + } + + cliErr := pausePointWaitError("/tmp/MyProject", waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 1, + }, response, pausePointWaitStateExpired) + + if cliErr.Details["expired"] != true { + t.Fatalf("expired detail mismatch: %#v", cliErr.Details) + } +} + // Verifies wait-for-pause-point does one final status probe before treating timeout as missed. func TestRunWaitForPausePointReturnsFinalHitAtTimeoutBoundary(t *testing.T) { originalQuery := queryPausePointStatus @@ -678,7 +752,15 @@ func TestRunPausePointStatusReturnsCurrentStatus(t *testing.T) { connection unityipc.Connection, id string, ) (pausePointStatusResponse, error) { - return pausePointStatusResponse{Id: id, Status: pausePointStatusEnabled, IsEnabled: true}, nil + return pausePointStatusResponse{ + Id: id, + Status: pausePointStatusEnabled, + IsEnabled: true, + EnabledAtUtc: "2026-06-03T00:00:00.0000000Z", + RemainingMilliseconds: 30000, + Generation: 3, + RecommendedNextAction: "Clear this marker, then re-enable it with the same Id and TimeoutSeconds values.", + }, nil } var stdout bytes.Buffer @@ -700,6 +782,99 @@ func TestRunPausePointStatusReturnsCurrentStatus(t *testing.T) { if response.Status != pausePointStatusEnabled { t.Fatalf("status mismatch: %#v", response) } + if response.EnabledAtUtc != "2026-06-03T00:00:00.0000000Z" { + t.Fatalf("enabledAtUtc mismatch: %#v", response) + } + if response.RemainingMilliseconds != 30000 { + t.Fatalf("remaining milliseconds mismatch: %#v", response) + } + if response.Generation != 3 { + t.Fatalf("generation mismatch: %#v", response) + } + if response.RecommendedNextAction != "Clear this marker, then re-enable it with the same Id and TimeoutSeconds values." { + t.Fatalf("recommendedNextAction mismatch: %#v", response) + } +} + +// Verifies pause-point-status derives Expired from Status when older Unity packages omit the bool field. +func TestRunPausePointStatusDerivesExpiredFromStatus(t *testing.T) { + originalQuery := queryPausePointStatus + defer func() { + queryPausePointStatus = originalQuery + }() + + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + return pausePointStatusResponse{ + Id: id, + Status: pausePointStatusExpired, + }, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runPausePointStatusCommand( + context.Background(), + unityipc.Connection{ProjectRoot: "/tmp/MyProject"}, + []string{"--id", "jump"}, + &stdout, + &stderr) + + if code != 0 { + t.Fatalf("expected success, got %d with stderr %s", code, stderr.String()) + } + var response pausePointStatusResponse + if err := json.Unmarshal(stdout.Bytes(), &response); err != nil { + t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String()) + } + if !response.Expired { + t.Fatalf("expired mismatch: %#v", response) + } +} + +// Verifies pause-point-status derives remaining time when older Unity packages omit the field. +func TestRunPausePointStatusDerivesRemainingTimeFromOlderResponse(t *testing.T) { + originalQuery := queryPausePointStatus + defer func() { + queryPausePointStatus = originalQuery + }() + + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + return pausePointStatusResponse{ + Id: id, + Status: pausePointStatusEnabled, + IsEnabled: true, + TimeoutSeconds: 30, + ElapsedSinceEnabledMilliseconds: 1200, + }, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runPausePointStatusCommand( + context.Background(), + unityipc.Connection{ProjectRoot: "/tmp/MyProject"}, + []string{"--id", "jump"}, + &stdout, + &stderr) + + if code != 0 { + t.Fatalf("expected success, got %d with stderr %s", code, stderr.String()) + } + var response pausePointStatusResponse + if err := json.Unmarshal(stdout.Bytes(), &response); err != nil { + t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String()) + } + if response.RemainingMilliseconds != 28800 { + t.Fatalf("remaining milliseconds mismatch: %#v", response) + } } // Verifies pause-point-status requires a marker id.