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
@@ -0,0 +1,340 @@
package projectrunner

import (
"bytes"
"context"
"strings"
"testing"
"time"

clierrors "github.com/hatayama/unity-cli-loop/common/errors"
"github.com/hatayama/unity-cli-loop/common/unityipc"
)

// Verifies await on an already-hit continuous marker ignores the baseline snapshot and returns
// only after LastHitSequence advances.
func TestWaitForPausePointWaitsForNewHitOnAlreadyHitContinuousMarker(t *testing.T) {
originalQuery := queryPausePointStatus
originalPoll := pausePointStatusPoll
pausePointStatusPoll = time.Millisecond
defer func() {
queryPausePointStatus = originalQuery
pausePointStatusPoll = originalPoll
}()

queryCount := 0
queryPausePointStatus = func(
ctx context.Context,
connection unityipc.Connection,
id string,
) (pausePointStatusResponse, error) {
queryCount++
sequence := 5
if queryCount >= 2 {
sequence = 6
}
return pausePointStatusResponse{
Id: id,
Status: pausePointStatusHit,
IsHit: true,
HitCount: sequence,
Mode: pausePointModeContinuous,
LastHitSequence: sequence,
EditorState: pausePointEditorState{IsPlaying: true, IsPaused: true, CapturedAt: "PausePointHit"},
}, nil
}

response, state, _, _, hasNewHitBaseline, err := waitForPausePoint(
context.Background(),
unityipc.Connection{},
waitForPausePointOptions{
id: "jump",
timeoutSeconds: 1,
timeout: time.Second,
},
)
if err != nil {
t.Fatalf("waitForPausePoint failed: %v", err)
}
if state != pausePointWaitStateHit {
t.Fatalf("state mismatch: %s", state)
}
if !hasNewHitBaseline {
t.Fatal("expected a new-hit baseline for an already-hit continuous marker")
}
if response.LastHitSequence != 6 {
t.Fatalf("expected the advanced hit sequence, got %#v", response)
}
if queryCount < 2 {
t.Fatalf("expected at least two status polls, got %d", queryCount)
}
}

// Verifies await on an already-hit continuous marker times out with PAUSE_POINT_WAIT_TIMEOUT and
// the already-hit baseline hint when LastHitSequence never advances, without clearing the still-armed
// marker (the hint tells the caller to await again).
func TestWaitForPausePointTimesOutWaitingForNewHitOnContinuousMarker(t *testing.T) {
originalQuery := queryPausePointStatus
originalClear := clearPausePointStatus
originalPoll := pausePointStatusPoll
pausePointStatusPoll = time.Millisecond
defer func() {
queryPausePointStatus = originalQuery
clearPausePointStatus = originalClear
pausePointStatusPoll = originalPoll
}()

queryPausePointStatus = func(
ctx context.Context,
connection unityipc.Connection,
id string,
) (pausePointStatusResponse, error) {
return pausePointStatusResponse{
Id: id,
Status: pausePointStatusHit,
IsHit: true,
HitCount: 5,
Mode: pausePointModeContinuous,
LastHitSequence: 5,
EditorState: pausePointEditorState{IsPlaying: true, IsPaused: true, CapturedAt: "PausePointHit"},
}, nil
}
clearCalls := 0
clearPausePointStatus = func(
ctx context.Context,
connection unityipc.Connection,
id string,
) (pausePointStatusResponse, error) {
clearCalls++
t.Fatal("timeout while waiting for a new hit must not clear the still-armed continuous marker")
return pausePointStatusResponse{}, nil
}

stderr := &bytes.Buffer{}
exitCode := runWaitForPausePoint(
context.Background(),
unityipc.Connection{ProjectRoot: "/tmp/MyProject"},
waitForPausePointOptions{
id: "jump",
timeoutSeconds: 1,
timeout: 40 * time.Millisecond,
},
&bytes.Buffer{},
stderr,
)
if exitCode != 1 {
t.Fatalf("exit code mismatch: %d", exitCode)
}
if clearCalls != 0 {
t.Fatalf("expected clear not to run, got %d calls", clearCalls)
}
stderrText := stderr.String()
if !strings.Contains(stderrText, clierrors.ErrorCodePausePointWaitTimeout) {
t.Fatalf("expected %s, got stderr: %s", clierrors.ErrorCodePausePointWaitTimeout, stderrText)
}
if !strings.Contains(stderrText, pausePointHintAlreadyHitWaitingForNew) {
t.Fatalf("expected already-hit baseline hint, got stderr: %s", stderrText)
}
if strings.Contains(stderrText, clierrors.ErrorCodePausePointExpired) {
t.Fatalf("timeout must not be reclassified as expired: %s", stderrText)
}
}

// Verifies a transient arm-query failure does not decide "no baseline", so a later stale continuous
// Hit is not returned as an immediate wait success.
func TestWaitForPausePointBaseliningSurvivesTransientArmQueryFailure(t *testing.T) {
originalQuery := queryPausePointStatus
originalClear := clearPausePointStatus
originalPoll := pausePointStatusPoll
pausePointStatusPoll = time.Millisecond
defer func() {
queryPausePointStatus = originalQuery
clearPausePointStatus = originalClear
pausePointStatusPoll = originalPoll
}()

queryCount := 0
queryPausePointStatus = func(
ctx context.Context,
connection unityipc.Connection,
id string,
) (pausePointStatusResponse, error) {
queryCount++
if queryCount == 1 {
return pausePointStatusResponse{}, context.DeadlineExceeded
}
return pausePointStatusResponse{
Id: id,
Status: pausePointStatusHit,
IsHit: true,
HitCount: 5,
Mode: pausePointModeContinuous,
LastHitSequence: 5,
EditorState: pausePointEditorState{IsPlaying: true, IsPaused: true, CapturedAt: "PausePointHit"},
}, nil
}
clearPausePointStatus = func(
ctx context.Context,
connection unityipc.Connection,
id string,
) (pausePointStatusResponse, error) {
t.Fatal("baseline timeout must not clear the still-armed continuous marker")
return pausePointStatusResponse{}, nil
}

stderr := &bytes.Buffer{}
exitCode := runWaitForPausePoint(
context.Background(),
unityipc.Connection{ProjectRoot: "/tmp/MyProject"},
waitForPausePointOptions{
id: "jump",
timeoutSeconds: 1,
timeout: 40 * time.Millisecond,
resumePlay: true,
},
&bytes.Buffer{},
stderr,
)
if exitCode != 1 {
t.Fatalf("exit code mismatch: %d", exitCode)
}
stderrText := stderr.String()
if !strings.Contains(stderrText, clierrors.ErrorCodePausePointWaitTimeout) {
t.Fatalf("expected %s after a stale continuous Hit, got stderr: %s", clierrors.ErrorCodePausePointWaitTimeout, stderrText)
}
if !strings.Contains(stderrText, pausePointHintAlreadyHitWaitingForNew) {
t.Fatalf("expected already-hit baseline hint, got stderr: %s", stderrText)
}
}

// Verifies enable --await never baselining a Hit that raced in before the first status query.
func TestWaitForPausePointAcceptsImmediateHitWhenMarkerJustEnabled(t *testing.T) {
originalQuery := queryPausePointStatus
originalPoll := pausePointStatusPoll
pausePointStatusPoll = time.Hour
defer func() {
queryPausePointStatus = originalQuery
pausePointStatusPoll = originalPoll
}()

queryCount := 0
queryPausePointStatus = func(
ctx context.Context,
connection unityipc.Connection,
id string,
) (pausePointStatusResponse, error) {
queryCount++
return pausePointStatusResponse{
Id: id,
Status: pausePointStatusHit,
IsHit: true,
HitCount: 1,
Mode: pausePointModeContinuous,
LastHitSequence: 1,
EditorState: pausePointEditorState{IsPlaying: true, IsPaused: true, CapturedAt: "PausePointHit"},
}, nil
}

response, state, _, _, hasNewHitBaseline, err := waitForPausePoint(
context.Background(),
unityipc.Connection{},
waitForPausePointOptions{
id: "jump",
timeoutSeconds: 1,
timeout: time.Second,
markerJustEnabled: true,
},
)
if err != nil {
t.Fatalf("waitForPausePoint failed: %v", err)
}
if state != pausePointWaitStateHit {
t.Fatalf("state mismatch: %s", state)
}
if hasNewHitBaseline {
t.Fatal("enable --await must not establish a new-hit baseline")
}
if response.LastHitSequence != 1 {
t.Fatalf("expected the raced enable-time hit, got %#v", response)
}
if queryCount != 1 {
t.Fatalf("expected a single status query, got %d", queryCount)
}
}

// Verifies an already-hit single-shot marker still returns immediately (baseline not applied).
func TestWaitForPausePointReturnsImmediatelyForAlreadyHitSingleShotMarker(t *testing.T) {
originalQuery := queryPausePointStatus
originalPoll := pausePointStatusPoll
pausePointStatusPoll = time.Hour
defer func() {
queryPausePointStatus = originalQuery
pausePointStatusPoll = originalPoll
}()

queryCount := 0
queryPausePointStatus = func(
ctx context.Context,
connection unityipc.Connection,
id string,
) (pausePointStatusResponse, error) {
queryCount++
return pausePointStatusResponse{
Id: id,
Status: pausePointStatusHit,
IsHit: true,
HitCount: 1,
Mode: "single-shot",
LastHitSequence: 1,
EditorState: pausePointEditorState{IsPlaying: true, IsPaused: true, CapturedAt: "PausePointHit"},
}, nil
}

response, state, _, _, hasNewHitBaseline, err := waitForPausePoint(
context.Background(),
unityipc.Connection{},
waitForPausePointOptions{
id: "jump",
timeoutSeconds: 1,
timeout: time.Second,
},
)
if err != nil {
t.Fatalf("waitForPausePoint failed: %v", err)
}
if state != pausePointWaitStateHit {
t.Fatalf("state mismatch: %s", state)
}
if hasNewHitBaseline {
t.Fatal("single-shot must not establish a new-hit baseline")
}
if response.LastHitSequence != 1 {
t.Fatalf("response mismatch: %#v", response)
}
if queryCount != 1 {
t.Fatalf("expected a single status query, got %d", queryCount)
}
}

// Verifies the timeout hint for an already-hit baseline is distinct from the generic paused hint.
func TestPausePointTimeoutHintForNewHitBaseline(t *testing.T) {
response := pausePointStatusResponse{
Id: "jump",
Status: pausePointStatusHit,
Mode: pausePointModeContinuous,
LastHitSequence: 5,
EditorState: pausePointEditorState{IsPlaying: true, IsPaused: true, CapturedAt: "PausePointHit"},
HitCount: 5,
}
cliErr := pausePointWaitError("/tmp/MyProject", waitForPausePointOptions{
id: "jump",
timeoutSeconds: 1,
}, response, pausePointWaitStateTimeout, true)

if cliErr.ErrorCode != clierrors.ErrorCodePausePointWaitTimeout {
t.Fatalf("error code mismatch: %s", cliErr.ErrorCode)
}
if cliErr.Details["Hint"] != pausePointHintAlreadyHitWaitingForNew {
t.Fatalf("hint mismatch: %#v", cliErr.Details)
}
}
10 changes: 7 additions & 3 deletions cli/project-runner/internal/projectrunner/pause_point_enable.go
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@ func runEnablePausePointAndAwait(
triggerArgs: triggerArgs,
startPath: startPath,
resumePlay: resumePlay,
markerJustEnabled: true,
}

return runPausePointWaitAfterEnable(
Expand Down Expand Up @@ -382,7 +383,7 @@ func runPausePointWaitAfterEnable(
stderr io.Writer,
) int {
spinner := clicore.NewToolSpinner(stderr, pausePointEnableCommandName)
response, state, triggerResult, resumeResult, err := waitForPausePoint(ctx, connection, options)
response, state, triggerResult, resumeResult, hasNewHitBaseline, err := waitForPausePoint(ctx, connection, options)
spinner.Stop()
if err != nil {
clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{
Expand Down Expand Up @@ -432,11 +433,14 @@ func runPausePointWaitAfterEnable(
return 0
}

if state == pausePointWaitStateTimeout {
// Why skip clear when hasNewHitBaseline: the continuous/trace marker is still armed, and the
// timeout hint tells the caller to await again (with --resume-play). Clearing here would disarm
// it and discard the raw capture holder, making that recovery path impossible.
if state == pausePointWaitStateTimeout && !hasNewHitBaseline {
clearPausePointAfterWaitTimeout(ctx, connection, options.id)
}

waitErr := pausePointWaitError(connection.ProjectRoot, options, response, state)
waitErr := pausePointWaitError(connection.ProjectRoot, options, response, state, hasNewHitBaseline)
waitErr.Command = pausePointEnableCommandName
if enableFields.Warning != "" {
waitErr.Details["EnableWarning"] = enableFields.Warning
Expand Down
Loading