返回 DeepSeek-Reasonix
jobs_extra_test.go
根目录 / internal / jobs / jobs_extra_test.go
1 package jobs
2
3 import (
4 "context"
5 "io"
6 "testing"
7 "time"
8
9 "reasonix/internal/event"
10 )
11
12 type typedNilJobSink struct{}
13
14 func (*typedNilJobSink) Emit(event.Event) {}
15
16 func TestNewManagerTreatsTypedNilSinkAsDiscard(t *testing.T) {
17 var sink *typedNilJobSink
18 m := NewManager(sink)
19 defer m.Close()
20
21 j := m.Start("bash", "typed nil sink", func(context.Context, io.Writer) (string, error) {
22 return "done", nil
23 })
24 res := m.Wait(context.Background(), []string{j.ID}, 1000)
25 if len(res) != 1 || res[0].Status != Done {
26 t.Fatalf("job result = %+v, want one done job", res)
27 }
28 }
29
30 // --- Wait with timeout ---
31
32 func TestWaitTimeout(t *testing.T) {
33 m := NewManager(event.Discard)
34 defer m.Close()
35
36 j := m.Start("bash", "", func(ctx context.Context, _ io.Writer) (string, error) {
37 <-ctx.Done()
38 return "", ctx.Err()
39 })
40 // Wait with a very short timeout — the job won't finish in time.
41 res := m.Wait(context.Background(), []string{j.ID}, 1)
42 if len(res) != 1 {
43 t.Fatalf("want 1 result, got %d", len(res))
44 }
45 // Should still be running (timeout expired before completion).
46 if res[0].Status != Running {
47 t.Errorf("status = %q, want running", res[0].Status)
48 }
49 m.Kill(j.ID)
50 }
51
52 // --- Wait with empty ids waits for all running ---
53
54 func TestWaitAllRunning(t *testing.T) {
55 m := NewManager(event.Discard)
56 defer m.Close()
57
58 // Jobs block until cancelled so they are still running when Wait resolves the
59 // "all running" set — instant-returning jobs could finish first and be missed,
60 // which is exactly the resolution this test must observe deterministically. A
61 // short timeout returns the still-running snapshot.
62 j1 := m.Start("bash", "", func(ctx context.Context, _ io.Writer) (string, error) {
63 <-ctx.Done()
64 return "", ctx.Err()
65 })
66 j2 := m.Start("bash", "", func(ctx context.Context, _ io.Writer) (string, error) {
67 <-ctx.Done()
68 return "", ctx.Err()
69 })
70 res := m.Wait(context.Background(), nil, 1)
71 if len(res) != 2 {
72 t.Fatalf("want 2 results, got %d", len(res))
73 }
74 ids := map[string]bool{res[0].ID: true, res[1].ID: true}
75 if !ids[j1.ID] || !ids[j2.ID] {
76 t.Errorf("results missing expected ids: %v", ids)
77 }
78 m.Kill(j1.ID)
79 m.Kill(j2.ID)
80 }
81
82 // --- Output with unknown id ---
83
84 func TestOutputUnknownID(t *testing.T) {
85 m := NewManager(event.Discard)
86 defer m.Close()
87
88 _, _, ok := m.Output("nonexistent-id")
89 if ok {
90 t.Error("Output for unknown id should return ok=false")
91 }
92 }
93
94 // --- Kill with unknown id ---
95
96 func TestKillUnknownID(t *testing.T) {
97 m := NewManager(event.Discard)
98 defer m.Close()
99
100 if m.Kill("nonexistent-id") {
101 t.Error("Kill for unknown id should return false")
102 }
103 }
104
105 // --- startedText ---
106
107 func TestStartedTextWithLabel(t *testing.T) {
108 got := startedText("bash", "bash-1", "my-label")
109 if got != "background bash started: bash-1 (my-label)" {
110 t.Errorf("startedText = %q", got)
111 }
112 }
113
114 func TestStartedTextWithoutLabel(t *testing.T) {
115 got := startedText("task", "task-1", "")
116 if got != "background task started: task-1" {
117 t.Errorf("startedText = %q", got)
118 }
119 }
120
121 // --- DrainCompletedNote with multiple jobs ---
122
123 func TestDrainMultiple(t *testing.T) {
124 m := NewManager(event.Discard)
125 defer m.Close()
126
127 m.Start("bash", "", func(_ context.Context, _ io.Writer) (string, error) {
128 return "", nil
129 })
130 m.Start("task", "label", func(_ context.Context, _ io.Writer) (string, error) {
131 return "answer", nil
132 })
133 m.Wait(context.Background(), nil, 5)
134 note := m.DrainCompletedNote()
135 if note == "" {
136 t.Fatal("drain should not be empty after 2 completions")
137 }
138 }
139
140 // --- Close is idempotent ---
141
142 func TestCloseIdempotent(t *testing.T) {
143 m := NewManager(event.Discard)
144 m.Start("bash", "", func(_ context.Context, _ io.Writer) (string, error) {
145 return "", nil
146 })
147 m.Close()
148 m.Close() // should not panic
149 }
150
151 // --- Running with no jobs ---
152
153 func TestRunningEmpty(t *testing.T) {
154 m := NewManager(event.Discard)
155 defer m.Close()
156 if r := m.Running(); len(r) != 0 {
157 t.Errorf("Running() = %d, want 0", len(r))
158 }
159 }
160
161 // --- Job with error sets Failed ---
162
163 func TestJobFailed(t *testing.T) {
164 m := NewManager(event.Discard)
165 defer m.Close()
166
167 j := m.Start("bash", "", func(_ context.Context, _ io.Writer) (string, error) {
168 return "", io.ErrUnexpectedEOF
169 })
170 res := m.Wait(context.Background(), []string{j.ID}, 5)
171 if len(res) != 1 || res[0].Status != Failed {
172 t.Fatalf("want Failed, got %+v", res)
173 }
174 }
175
176 // --- Job with result and no error sets Done ---
177
178 func TestJobWithResult(t *testing.T) {
179 m := NewManager(event.Discard)
180 defer m.Close()
181
182 j := m.Start("task", "", func(_ context.Context, _ io.Writer) (string, error) {
183 return "final answer", nil
184 })
185 res := m.Wait(context.Background(), []string{j.ID}, 5)
186 if len(res) != 1 || res[0].Status != Done {
187 t.Fatalf("want Done, got %+v", res)
188 }
189 if res[0].Output != "final answer" {
190 t.Errorf("output = %q, want \"final answer\"", res[0].Output)
191 }
192 }
193
194 // --- Context injection ---
195
196 func TestWithManagerFromContext(t *testing.T) {
197 m := NewManager(event.Discard)
198 defer m.Close()
199
200 ctx := WithManager(context.Background(), m)
201 got, ok := FromContext(ctx)
202 if !ok || got != m {
203 t.Error("FromContext should return the manager")
204 }
205 }
206
207 func TestFromContextEmpty(t *testing.T) {
208 _, ok := FromContext(context.Background())
209 if ok {
210 t.Error("plain context should return ok=false")
211 }
212 }
213
214 // --- Status constants ---
215
216 func TestStatusConstants(t *testing.T) {
217 if Running != "running" {
218 t.Errorf("Running = %q", Running)
219 }
220 if Done != "done" {
221 t.Errorf("Done = %q", Done)
222 }
223 if Failed != "failed" {
224 t.Errorf("Failed = %q", Failed)
225 }
226 if Killed != "killed" {
227 t.Errorf("Killed = %q", Killed)
228 }
229 }
230
231 // --- nowMs ---
232
233 func TestNowMs(t *testing.T) {
234 before := time.Now().UnixMilli()
235 got := nowMs()
236 after := time.Now().UnixMilli()
237 if got < before || got > after {
238 t.Errorf("nowMs() = %d, not in [%d, %d]", got, before, after)
239 }
240 }
241
241 lines GO