package app import ( "regexp" "testing" ) func mustCompile(t *testing.T, p string) *regexp.Regexp { t.Helper() re, err := regexp.Compile(p) if err != nil { t.Fatalf("regex %q: %v", p, err) } return re } func TestClassifyOutputActivity(t *testing.T) { cfg := &resolvedIdleDetection{strategy: StrategyOutputActivity, idleThresholdMS: 2000} cases := []struct { name string idleMS int64 want IdleState }{ {"fresh-spawn no writes", 0, StateWorking}, {"recent activity", 500, StateWorking}, {"under threshold", 1999, StateWorking}, {"at threshold", 2000, StateIdle}, {"over threshold", 5000, StateIdle}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got, _ := classify(cfg, false, false, tc.idleMS, 0, "", nil) if got != tc.want { t.Fatalf("got %q want %q", got, tc.want) } }) } } func TestClassifyTitleStability(t *testing.T) { cfg := &resolvedIdleDetection{strategy: StrategyOSCTitleStability, idleThresholdMS: 2000} // Title change recent → working. if got, _ := classify(cfg, false, false, 9999, 500, "step 3", nil); got != StateWorking { t.Fatalf("recent title change: got %q", got) } // Title stable past threshold → idle. if got, _ := classify(cfg, false, false, 9999, 5000, "step 3", nil); got != StateIdle { t.Fatalf("stable title: got %q", got) } // No title yet: fall back to output activity. if got, _ := classify(cfg, false, false, 100, 0, "", nil); got != StateWorking { t.Fatalf("no title yet, recent output: got %q", got) } if got, _ := classify(cfg, false, false, 5000, 0, "", nil); got != StateIdle { t.Fatalf("no title yet, output idle: got %q", got) } } func TestClassifyTitleStatus(t *testing.T) { cfg := &resolvedIdleDetection{ strategy: StrategyOSCTitleStatus, idleThresholdMS: 2000, titleStatusMap: map[string]IdleState{ "thinking": StateThinking, "permission": StatePermission, "error": StateError, }, } if got, _ := classify(cfg, false, false, 9999, 500, "Thinking…", nil); got != StateThinking { t.Fatalf("thinking title: got %q", got) } if got, _ := classify(cfg, false, false, 9999, 500, "Waiting for permission", nil); got != StatePermission { t.Fatalf("permission title: got %q", got) } // No match in map → fall back to stability. if got, _ := classify(cfg, false, false, 9999, 5000, "ready", nil); got != StateIdle { t.Fatalf("unmatched title, stable: got %q", got) } } func TestClassifyPromoterRegex(t *testing.T) { cfg := &resolvedIdleDetection{ strategy: StrategyOutputActivity, idleThresholdMS: 2000, permissionRegexes: []*regexp.Regexp{mustCompile(t, `Approve\?`)}, errorRegexes: []*regexp.Regexp{mustCompile(t, `panic:`)}, thinkingRegexes: []*regexp.Regexp{mustCompile(t, `Thinking`)}, } // Permission promoter beats idle. if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("Approve? [y/n]")); got != StatePermission { t.Fatalf("permission promoter: got %q", got) } // Error trumps permission. if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("panic: bad\nApprove?")); got != StateError { t.Fatalf("error promoter beats permission: got %q", got) } // Thinking promoter on idle output. if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("Thinking…")); got != StateThinking { t.Fatalf("thinking promoter: got %q", got) } } func TestClassifyExitTerminal(t *testing.T) { cfg := &resolvedIdleDetection{strategy: StrategyOutputActivity, idleThresholdMS: 2000} if got, _ := classify(cfg, true, true, 0, 0, "", nil); got != StateError { t.Fatalf("non-zero exit: got %q", got) } if got, _ := classify(cfg, true, false, 0, 0, "", nil); got != StateIdle { t.Fatalf("clean exit: got %q", got) } }