| 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 |