返回 DeepSeek-Reasonix
domain_test.go
根目录 / internal / goal / domain_test.go
1 package goal
2
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "testing"
8 "time"
9 )
10
11 func newTestMachine(t *testing.T) *Machine {
12 t.Helper()
13 clock := time.Date(2026, 9, 13, 10, 0, 0, 0, time.UTC)
14 return NewMachine(
15 func() time.Time { return clock },
16 func() string { return "goal-1" },
17 )
18 }
19
20 func TestCreateDefaultsToUnlimitedAndArmed(t *testing.T) {
21 m := newTestMachine(t)
22 view, err := m.Create(CreateRequest{Objective: " ship the runtime "})
23 if err != nil {
24 t.Fatal(err)
25 }
26 if view.ID != "goal-1" || view.Revision != 1 || view.Objective != "ship the runtime" {
27 t.Fatalf("view = %+v", view)
28 }
29 if view.MaxGoalRounds != nil {
30 t.Fatalf("default limit = %v, want unlimited", *view.MaxGoalRounds)
31 }
32 if view.Activation != ActivationArmed || view.Phase != PhaseActive {
33 t.Fatalf("phase/activation = %s/%s", view.Phase, view.Activation)
34 }
35 }
36
37 func TestExplicitReplaceUsesNewIdentityAndPreservesClearTombstone(t *testing.T) {
38 clock := time.Date(2026, 9, 13, 10, 0, 0, 0, time.UTC)
39 next := 0
40 m := NewMachine(func() time.Time { return clock }, func() string {
41 next++
42 return fmt.Sprintf("goal-%d", next)
43 })
44 first, err := m.Create(CreateRequest{Objective: "first"})
45 if err != nil {
46 t.Fatal(err)
47 }
48 second, err := m.Replace(CreateRequest{Objective: "second"})
49 if err != nil {
50 t.Fatal(err)
51 }
52 if second.ID == first.ID || second.Revision != 1 || second.Objective != "second" {
53 t.Fatalf("replacement = %+v, first = %+v", second, first)
54 }
55 encoded, err := m.Encode()
56 if err != nil {
57 t.Fatal(err)
58 }
59 var document struct {
60 Cleared *Ref `json:"cleared"`
61 }
62 if err := json.Unmarshal(encoded, &document); err != nil {
63 t.Fatal(err)
64 }
65 if document.Cleared == nil || *document.Cleared != first.Ref() {
66 t.Fatalf("clear tombstone = %+v, want %+v", document.Cleared, first.Ref())
67 }
68 }
69
70 func TestLifecycleUsesExactRevision(t *testing.T) {
71 m := newTestMachine(t)
72 created, err := m.Create(CreateRequest{Objective: "ship"})
73 if err != nil {
74 t.Fatal(err)
75 }
76 edited, err := m.Edit(created.Ref(), EditRequest{Objective: stringPtr("ship safely")})
77 if err != nil {
78 t.Fatal(err)
79 }
80 if edited.Revision != 2 || edited.Objective != "ship safely" {
81 t.Fatalf("edited = %+v", edited)
82 }
83 _, err = m.Complete(created.Ref())
84 var goalErr *Error
85 if !errors.As(err, &goalErr) || goalErr.Code != ErrStaleRevision {
86 t.Fatalf("stale complete error = %v", err)
87 }
88 }
89
90 func TestRoundsDoNotChangeRevisionAndRespectExplicitLimit(t *testing.T) {
91 m := newTestMachine(t)
92 limit := uint64(2)
93 created, err := m.Create(CreateRequest{Objective: "ship", MaxGoalRounds: &limit})
94 if err != nil {
95 t.Fatal(err)
96 }
97 first, err := m.AdmitRound(created.Ref())
98 if err != nil {
99 t.Fatal(err)
100 }
101 second, err := m.AdmitRound(created.Ref())
102 if err != nil {
103 t.Fatal(err)
104 }
105 if second.Revision != created.Revision || second.RoundsStarted != 2 || first.RoundsStarted != 1 {
106 t.Fatalf("round views = %+v / %+v", first, second)
107 }
108 _, err = m.AdmitRound(created.Ref())
109 if !errors.As(err, new(*Error)) || ErrorCodeOf(err) != ErrRoundLimit {
110 t.Fatalf("third round error = %v", err)
111 }
112 }
113
114 func TestColdRestoreIsDisarmedAndPreservesUnknownFields(t *testing.T) {
115 m := newTestMachine(t)
116 raw := []byte(`{"version":1,"current":{"id":"g","revision":4,"objective":"resume me","phase":"active","maxGoalRounds":null,"roundsStarted":8,"createdAt":"2026-09-13T10:00:00Z","updatedAt":"2026-09-13T10:00:00Z"},"futurePolicy":{"mode":"adaptive"}}`)
117 view, err := m.Restore(raw)
118 if err != nil {
119 t.Fatal(err)
120 }
121 if view == nil || view.Activation != ActivationDisarmed || view.RoundsStarted != 8 {
122 t.Fatalf("restored = %+v", view)
123 }
124 encoded, err := m.Encode()
125 if err != nil {
126 t.Fatal(err)
127 }
128 var got map[string]json.RawMessage
129 if err := json.Unmarshal(encoded, &got); err != nil {
130 t.Fatal(err)
131 }
132 if string(got["futurePolicy"]) != `{"mode":"adaptive"}` {
133 t.Fatalf("futurePolicy = %s", got["futurePolicy"])
134 }
135 }
136
137 func TestPausedGoalNeedsExplicitUserResume(t *testing.T) {
138 m := newTestMachine(t)
139 created, _ := m.Create(CreateRequest{Objective: "ship"})
140 paused, err := m.Pause(created.Ref())
141 if err != nil {
142 t.Fatal(err)
143 }
144 if paused.Phase != PhasePaused || paused.Activation != ActivationDisarmed {
145 t.Fatalf("paused = %+v", paused)
146 }
147 if _, err := m.Resume(paused.Ref(), false); ErrorCodeOf(err) != ErrUserAuthorityRequired {
148 t.Fatalf("model resume error = %v", err)
149 }
150 resumed, err := m.Resume(paused.Ref(), true)
151 if err != nil || resumed.Phase != PhaseActive || resumed.Activation != ActivationArmed {
152 t.Fatalf("resumed = %+v, err = %v", resumed, err)
153 }
154 }
155
156 func TestBlockedRequiresReasonAndMinimumAutomaticRounds(t *testing.T) {
157 m := newTestMachine(t)
158 created, _ := m.Create(CreateRequest{Objective: "ship"})
159 if _, err := m.Block(created.Ref(), BlockReason{}, false, 3); ErrorCodeOf(err) != ErrInvalidBlockReason {
160 t.Fatalf("empty reason error = %v", err)
161 }
162 if _, err := m.Block(created.Ref(), BlockReason{Code: "dependency", Message: "waiting"}, false, 3); ErrorCodeOf(err) != ErrBlockedTooEarly {
163 t.Fatalf("early block error = %v", err)
164 }
165 for range 3 {
166 if _, err := m.AdmitRound(created.Ref()); err != nil {
167 t.Fatal(err)
168 }
169 }
170 blocked, err := m.Block(created.Ref(), BlockReason{Code: "dependency", Message: "waiting"}, false, 3)
171 if err != nil || blocked.Phase != PhaseBlocked || blocked.Activation != ActivationDisarmed {
172 t.Fatalf("blocked = %+v, err = %v", blocked, err)
173 }
174 }
175
176 func TestCompleteMayHappenInFirstRound(t *testing.T) {
177 m := newTestMachine(t)
178 created, _ := m.Create(CreateRequest{Objective: "small but durable"})
179 completed, err := m.Complete(created.Ref())
180 if err != nil || completed.Phase != PhaseComplete || completed.Activation != ActivationDisarmed {
181 t.Fatalf("completed = %+v, err = %v", completed, err)
182 }
183 }
184
185 func TestDisarmDoesNotOverwriteTerminalStopReason(t *testing.T) {
186 m := newTestMachine(t)
187 created, err := m.Create(CreateRequest{Objective: "finish"})
188 if err != nil {
189 t.Fatal(err)
190 }
191 completed, err := m.Complete(created.Ref())
192 if err != nil {
193 t.Fatal(err)
194 }
195 view := m.Disarm("cancelled")
196 if view == nil || view.Phase != PhaseComplete || view.StopReason != completed.StopReason {
197 t.Fatalf("terminal disarm = %+v, want stop reason %q", view, completed.StopReason)
198 }
199 }
200
201 func TestUnlimitedGoalAdmitsPastHarnessDefaultCeiling(t *testing.T) {
202 m := newTestMachine(t)
203 created, err := m.Create(CreateRequest{Objective: "long-running target"})
204 if err != nil {
205 t.Fatal(err)
206 }
207 var view View
208 for range 300 {
209 view, err = m.AdmitRound(created.Ref())
210 if err != nil {
211 t.Fatal(err)
212 }
213 }
214 if view.RoundsStarted != 300 || view.MaxGoalRounds != nil || view.Revision != created.Revision {
215 t.Fatalf("unlimited goal view = %+v", view)
216 }
217 }
218
219 func TestUnknownVersionFailsClosed(t *testing.T) {
220 m := newTestMachine(t)
221 _, err := m.Restore([]byte(`{"version":2,"current":null}`))
222 if ErrorCodeOf(err) != ErrUnsupportedVersion {
223 t.Fatalf("restore error = %v", err)
224 }
225 }
226
227 func TestClonePreservesLiveActivationAndIsIndependent(t *testing.T) {
228 m := newTestMachine(t)
229 created, err := m.Create(CreateRequest{Objective: "ship"})
230 if err != nil {
231 t.Fatal(err)
232 }
233 clone := m.Clone()
234 if clone.Get().Activation != ActivationArmed {
235 t.Fatalf("clone activation = %s", clone.Get().Activation)
236 }
237 if _, err := clone.Complete(created.Ref()); err != nil {
238 t.Fatal(err)
239 }
240 if m.Get().Phase != PhaseActive || clone.Get().Phase != PhaseComplete {
241 t.Fatalf("original/clone = %s/%s", m.Get().Phase, clone.Get().Phase)
242 }
243 }
244
245 func stringPtr(value string) *string { return &value }
246
246 lines GO