返回 DeepSeek-Reasonix
heartbeat_persistence_test.go
根目录 / desktop / heartbeat_persistence_test.go
1 package main
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "os"
9 "path/filepath"
10 "testing"
11 "time"
12
13 "reasonix/internal/filelock"
14 )
15
16 func TestHeartbeatRunCompletionObservesDeletionBeforeNextTick(t *testing.T) {
17 isolateDesktopUserDirs(t)
18 engine := newHeartbeatEngine(nil)
19 if err := engine.saveTasks([]HeartbeatTask{{ID: "deleted", Title: "old", Interval: "1h", Enabled: true}}); err != nil {
20 t.Fatal(err)
21 }
22 snapshot, err := engine.readConfigSnapshot()
23 if err != nil {
24 t.Fatal(err)
25 }
26 engine.mu.Lock()
27 engine.recordConfigSnapshotLocked(snapshot)
28 engine.tasks = append([]HeartbeatTask(nil), snapshot.cfg.Tasks...)
29 if err := os.Remove(engine.configPath()); err != nil {
30 engine.mu.Unlock()
31 t.Fatal(err)
32 }
33 // Simulate a run that finishes before the scheduler's next external-edit
34 // adoption pass. The completion merge itself must observe the deletion.
35 engine.mergeRunUpdatesLocked(map[string]HeartbeatTask{"deleted": {ID: "deleted", LastRunAt: 123}})
36 deleted := engine.cfgDeleted
37 taskCount := len(engine.tasks)
38 engine.mu.Unlock()
39
40 if !deleted || taskCount != 0 {
41 t.Fatalf("run completion retained deleted config: tasks=%d deleted=%v", taskCount, deleted)
42 }
43 if _, err := os.Stat(engine.configPath()); !os.IsNotExist(err) {
44 t.Fatalf("run completion recreated deleted heartbeat config, stat err=%v", err)
45 }
46 }
47
48 func TestHeartbeatTaskLeaseIsCrossEngine(t *testing.T) {
49 isolateDesktopUserDirs(t)
50 first := newHeartbeatEngine(nil)
51 second := newHeartbeatEngine(nil)
52 release, err := first.tryAcquireTaskLease("same-task")
53 if err != nil {
54 t.Fatal(err)
55 }
56 if _, err := second.tryAcquireTaskLease("same-task"); !errors.Is(err, filelock.ErrHeld) {
57 t.Fatalf("second task lease err=%v, want filelock.ErrHeld", err)
58 }
59 release()
60 retry, err := second.tryAcquireTaskLease("same-task")
61 if err != nil {
62 t.Fatalf("task lease after release: %v", err)
63 }
64 retry()
65 }
66
67 func TestHeartbeatTaskLeaseCoversRunStatePersistence(t *testing.T) {
68 isolateDesktopUserDirs(t)
69 ctrl := &heartbeatSignalingCtrlStub{submittedSignal: make(chan struct{})}
70 app := NewApp()
71 app.tabs = map[string]*WorkspaceTab{
72 "heartbeat-tab": {
73 ID: "heartbeat-tab",
74 Scope: "global",
75 TopicID: "topic",
76 TopicTitle: "Heartbeat",
77 Ready: true,
78 Ctrl: ctrl,
79 disabledMCP: map[string]ServerView{},
80 },
81 }
82 app.tabOrder = []string{"heartbeat-tab"}
83 first := newHeartbeatEngine(app)
84 second := newHeartbeatEngine(nil)
85 task := HeartbeatTask{ID: "same-task", Title: "same", Prompt: "ping", Interval: "1h", Enabled: true, TopicID: "topic"}
86 if err := first.saveTasks([]HeartbeatTask{task}); err != nil {
87 t.Fatal(err)
88 }
89 first.ReloadConfig()
90
91 configRelease, err := filelock.Acquire(context.Background(), first.configPath()+".lock")
92 if err != nil {
93 t.Fatal(err)
94 }
95 result := make(chan HeartbeatTask, 1)
96 go func() { result <- first.executeTaskWithLease(task, nil) }()
97 <-ctrl.submittedSignal
98
99 if release, err := second.tryAcquireTaskLease(task.ID); !errors.Is(err, filelock.ErrHeld) {
100 if err == nil {
101 release()
102 }
103 t.Fatalf("lease became available before run-state persistence: %v", err)
104 }
105 configRelease()
106 completed := <-result
107 if completed.LastRunAt == 0 {
108 t.Fatal("successful execution did not update LastRunAt")
109 }
110 if release, err := second.tryAcquireTaskLease(task.ID); err != nil {
111 t.Fatalf("lease was not released after persistence: %v", err)
112 } else {
113 release()
114 }
115 onDisk := first.loadTasks()
116 if len(onDisk) != 1 || onDisk[0].LastRunAt != completed.LastRunAt {
117 t.Fatalf("lease released before durable completion: disk=%+v completed=%+v", onDisk, completed)
118 }
119 }
120
121 func TestHeartbeatScheduledTaskRevalidatesAfterLeaseHandoff(t *testing.T) {
122 isolateDesktopUserDirs(t)
123 first := newHeartbeatEngine(nil)
124 second := newHeartbeatEngine(nil)
125 now := time.Date(2026, 6, 18, 9, 2, 10, 0, time.UTC)
126 stale := HeartbeatTask{ID: "same-task", Title: "same", Interval: "* * * * *", Enabled: true}
127 if err := first.saveTasks([]HeartbeatTask{stale}); err != nil {
128 t.Fatal(err)
129 }
130 first.ReloadConfig()
131 second.ReloadConfig()
132
133 completed := stale
134 completed.LastRunAt = now.Add(-time.Second).UnixMilli()
135 first.mu.Lock()
136 first.mergeRunUpdatesLocked(map[string]HeartbeatTask{stale.ID: completed})
137 first.mu.Unlock()
138
139 got := second.executeScheduledTask(stale, now)
140 if got.LastRunAt != completed.LastRunAt {
141 t.Fatalf("stale owner did not adopt persisted occurrence: LastRunAt=%d, want %d", got.LastRunAt, completed.LastRunAt)
142 }
143 }
144
145 func TestMergeHeartbeatRunUpdatesKeepsRunHistory(t *testing.T) {
146 tasks := []HeartbeatTask{{ID: "t1", Title: "task", RunHistory: []HeartbeatRun{{At: 100, TopicID: "a"}}}}
147 updates := map[string]HeartbeatTask{
148 "t1": {ID: "t1", Title: "task", LastRunAt: 200, RunHistory: []HeartbeatRun{{At: 100, TopicID: "a"}, {At: 200, TopicID: "b"}}},
149 }
150 mergeHeartbeatRunUpdates(tasks, updates)
151 got := tasks[0].RunHistory
152 if len(got) != 2 {
153 t.Fatalf("run history len=%d, want 2 (deduped union)", len(got))
154 }
155 if got[0].At != 100 || got[1].At != 200 {
156 t.Fatalf("run history order=%v, want [100 200]", got)
157 }
158 if tasks[0].LastRunAt != 200 {
159 t.Fatalf("LastRunAt=%d, want 200", tasks[0].LastRunAt)
160 }
161 }
162
163 func TestMergeHeartbeatRunUpdatesCapsHistory(t *testing.T) {
164 tasks := []HeartbeatTask{{ID: "t1"}}
165 updates := map[string]HeartbeatTask{"t1": {ID: "t1"}}
166 history := make([]HeartbeatRun, 0, maxRunHistory+5)
167 for i := range maxRunHistory + 5 {
168 history = append(history, HeartbeatRun{At: int64(i)})
169 }
170 updates["t1"] = HeartbeatTask{ID: "t1", RunHistory: history}
171 mergeHeartbeatRunUpdates(tasks, updates)
172 if got := len(tasks[0].RunHistory); got != maxRunHistory {
173 t.Fatalf("run history len=%d, want %d", got, maxRunHistory)
174 }
175 if tasks[0].RunHistory[0].At != int64(5) {
176 t.Fatalf("oldest kept run At=%d, want 5", tasks[0].RunHistory[0].At)
177 }
178 }
179
180 // TestHeartbeatReplaceTasksPreservesRunHistory: 前端整表保存(ReplaceTasks)时,
181 // 前端快照可能不含引擎刚写入的 runHistory(竞态/旧 state),不得清空磁盘已有的历史。
182 // 回归:此前 ReplaceTasks 直接整表覆写,会把引擎已持久化的 runHistory 全部冲掉。
183 func TestHeartbeatReplaceTasksPreservesRunHistory(t *testing.T) {
184 isolateDesktopUserDirs(t)
185 engine := &HeartbeatEngine{}
186 // 引擎已执行两次:磁盘上有 2 条 runHistory
187 if err := engine.ReplaceTasks([]HeartbeatTask{{
188 ID: "t1",
189 Title: "task",
190 RunHistory: []HeartbeatRun{{At: 100, TopicID: "a"}, {At: 200, TopicID: "b"}},
191 }}); err != nil {
192 t.Fatalf("seed ReplaceTasks: %v", err)
193 }
194
195 // 前端旧快照:只改了 enabled,runHistory 字段缺失(竞态下 load 到旧数据)
196 if err := engine.ReplaceTasks([]HeartbeatTask{{
197 ID: "t1",
198 Title: "task",
199 Enabled: true,
200 }}); err != nil {
201 t.Fatalf("stale ReplaceTasks: %v", err)
202 }
203
204 got := engine.ListTasks()
205 if len(got) != 1 {
206 t.Fatalf("tasks len=%d, want 1", len(got))
207 }
208 if len(got[0].RunHistory) != 2 {
209 t.Fatalf("run history len=%d, want 2 (stale frontend save must not clear engine-written history): %+v", len(got[0].RunHistory), got[0].RunHistory)
210 }
211 }
212
213 // TestHeartbeatReplaceConfigPreservesRunHistory: ReplaceConfig(revision/ETag 校验的
214 // 前端保存)同样不得用旧快照清掉引擎已写入的 runHistory。
215 func TestHeartbeatReplaceConfigPreservesRunHistory(t *testing.T) {
216 isolateDesktopUserDirs(t)
217 engine := &HeartbeatEngine{}
218 seed := []HeartbeatTask{{ID: "t1", Title: "task", RunHistory: []HeartbeatRun{{At: 100, TopicID: "a"}}}}
219 view, err := engine.ReplaceConfig(HeartbeatConfigUpdate{Revision: 0, Tasks: seed})
220 if err != nil {
221 t.Fatalf("seed ReplaceConfig: %v", err)
222 }
223 // 引擎随后写入一条新执行(模拟后台执行落盘)
224 if err := engine.ReplaceTasks([]HeartbeatTask{{ID: "t1", Title: "task", RunHistory: []HeartbeatRun{{At: 100, TopicID: "a"}, {At: 200, TopicID: "b"}}}}); err != nil {
225 t.Fatalf("engine run write: %v", err)
226 }
227 // 前端旧快照(revision 过期场景改用全新 engine 读取磁盘模拟 stale load):
228 // 直接验证磁盘保护——重新加载磁盘后前端提交不含 runHistory 的旧快照
229 reloaded := &HeartbeatEngine{}
230 snap, err := reloaded.readConfigSnapshot()
231 if err != nil {
232 t.Fatalf("read snapshot: %v", err)
233 }
234 _ = view
235 stale := []HeartbeatTask{{ID: "t1", Title: "task", Enabled: true}}
236 protected := mergeHeartbeatDiskRunHistory(stale, snap.cfg.Tasks)
237 if len(protected[0].RunHistory) != 2 {
238 t.Fatalf("protected run history len=%d, want 2: %+v", len(protected[0].RunHistory), protected[0].RunHistory)
239 }
240 }
241
242 func TestMergeHeartbeatDiskRunHistoryPreservesAllEngineRunState(t *testing.T) {
243 submitted := []HeartbeatTask{{
244 ID: "t1",
245 Title: "edited title",
246 TopicID: "stale-topic",
247 LastRunAt: 100,
248 RunHistory: []HeartbeatRun{{At: 100, TopicID: "stale-topic"}},
249 }}
250 disk := []HeartbeatTask{{
251 ID: "t1",
252 Title: "old title",
253 TopicID: "fresh-topic",
254 LastRunAt: 200,
255 RunHistory: []HeartbeatRun{{At: 200, TopicID: "fresh-topic"}},
256 }}
257
258 got := mergeHeartbeatDiskRunHistory(submitted, disk)
259 if got[0].Title != "edited title" {
260 t.Fatalf("user-owned title=%q, want edited title", got[0].Title)
261 }
262 if got[0].TopicID != "fresh-topic" || got[0].LastRunAt != 200 {
263 t.Fatalf("engine run state rolled back: topic=%q lastRunAt=%d", got[0].TopicID, got[0].LastRunAt)
264 }
265 if len(got[0].RunHistory) != 2 {
266 t.Fatalf("run history len=%d, want union of both snapshots", len(got[0].RunHistory))
267 }
268 }
269
270 // TestHeartbeatMergeRunUpdatesPersistsRunHistory: 模拟 TriggerNow 的完整写盘链路——
271 // executeTaskWithLease 返回含 runHistory 的 t → mergeRunUpdatesLocked → 磁盘。验证 runHistory 真实落盘。
272 func TestHeartbeatMergeRunUpdatesPersistsRunHistory(t *testing.T) {
273 isolateDesktopUserDirs(t)
274 engine := &HeartbeatEngine{}
275 // 先建立基线任务(磁盘 + 内存一致)
276 seed := []HeartbeatTask{{ID: "t1", Title: "task", Enabled: true}}
277 if err := engine.ReplaceTasks(seed); err != nil {
278 t.Fatalf("seed: %v", err)
279 }
280 // 模拟 executeTaskWithLease 返回值:lastRunAt 更新 + runHistory 追加 1 条
281 updates := map[string]HeartbeatTask{
282 "t1": {
283 ID: "t1",
284 Title: "task",
285 Enabled: true,
286 LastRunAt: 200,
287 TopicID: "topic-b",
288 RunHistory: []HeartbeatRun{{At: 200, TopicID: "topic-b"}},
289 },
290 }
291 engine.mergeRunUpdatesLocked(updates)
292
293 // 从磁盘重新读,确认 runHistory 落盘
294 reloaded := &HeartbeatEngine{}
295 snap, err := reloaded.readConfigSnapshot()
296 if err != nil {
297 t.Fatalf("read snapshot: %v", err)
298 }
299 if len(snap.cfg.Tasks) != 1 {
300 t.Fatalf("tasks len=%d", len(snap.cfg.Tasks))
301 }
302 got := snap.cfg.Tasks[0]
303 if got.LastRunAt != 200 {
304 t.Fatalf("LastRunAt=%d, want 200", got.LastRunAt)
305 }
306 if len(got.RunHistory) != 1 {
307 t.Fatalf("run history on disk len=%d, want 1: %+v", len(got.RunHistory), got.RunHistory)
308 }
309 }
310
311 // TestCronDueDomDowOrSemantics: 标准 cron 中 day-of-month 与 day-of-week 双受限时
312 // 为 OR 语义(任一匹配即触发),非 AND。回归:此前实现要求两者同时匹配。
313 func TestCronDueDomDowOrSemantics(t *testing.T) {
314 // "0 9 1 * 1": fires on 1st of month OR Monday
315 // 2026-08-03 is a Monday, not the 1st → should fire
316 mon := time.Date(2026, 8, 3, 9, 0, 0, 0, time.Local)
317 if !cronDue("0 9 1 * 1", mon) {
318 t.Fatalf("Monday 09:00 should match (dow OR dom)")
319 }
320 // 2026-08-01 is a Saturday, not Monday → should fire (dom=1)
321 sat := time.Date(2026, 8, 1, 9, 0, 0, 0, time.Local)
322 if !cronDue("0 9 1 * 1", sat) {
323 t.Fatalf("1st of month should match (dow OR dom)")
324 }
325 // 2026-08-05 is Wednesday, not 1st/Monday → should NOT fire
326 wed := time.Date(2026, 8, 5, 9, 0, 0, 0, time.Local)
327 if cronDue("0 9 1 * 1", wed) {
328 t.Fatalf("Wednesday should not match")
329 }
330 // "0 9 * * 1": only dow restricted → Monday only
331 tue := time.Date(2026, 8, 4, 9, 0, 0, 0, time.Local)
332 if cronDue("0 9 * * 1", tue) {
333 t.Fatalf("Tuesday should not match dow=1-only")
334 }
335 // "0 9 1 * *": only dom restricted → 1st only
336 if cronDue("0 9 1 * *", mon) {
337 t.Fatalf("Monday (not 1st) should not match dom-only")
338 }
339 }
340
341 func TestCronStepAnchorsAndSingleValueSteps(t *testing.T) {
342 loc := time.UTC
343 if !cronDue("0 0 * */2 *", time.Date(2026, time.January, 1, 0, 0, 0, 0, loc)) {
344 t.Fatal("*/2 month step must anchor at January, the field minimum")
345 }
346 if cronDue("0 0 * */2 *", time.Date(2026, time.February, 1, 0, 0, 0, 0, loc)) {
347 t.Fatal("*/2 month step must skip February")
348 }
349 if !cronDue("0 0 */2 * *", time.Date(2026, time.January, 1, 0, 0, 0, 0, loc)) {
350 t.Fatal("*/2 day-of-month step must anchor at day 1")
351 }
352 if !isCronExpr("1/2 * * * *") {
353 t.Fatal("single-value step should be accepted consistently")
354 }
355 if !cronDue("1/2 * * * *", time.Date(2026, time.January, 1, 0, 3, 0, 0, loc)) {
356 t.Fatal("1/2 minute step should match 1, 3, 5, ...")
357 }
358 if cronDue("1/2 * * * *", time.Date(2026, time.January, 1, 0, 2, 0, 0, loc)) {
359 t.Fatal("1/2 minute step must not match minute 2")
360 }
361 }
362
363 func TestWeeksBetweenUsesCivilDatesAcrossDST(t *testing.T) {
364 loc, err := time.LoadLocation("America/New_York")
365 if err != nil {
366 t.Fatal(err)
367 }
368 for _, tc := range []struct {
369 name string
370 a time.Time
371 b time.Time
372 }{
373 {"spring forward", time.Date(2026, time.March, 2, 0, 0, 0, 0, loc), time.Date(2026, time.March, 16, 0, 0, 0, 0, loc)},
374 {"fall back", time.Date(2026, time.October, 26, 0, 0, 0, 0, loc), time.Date(2026, time.November, 9, 0, 0, 0, 0, loc)},
375 } {
376 t.Run(tc.name, func(t *testing.T) {
377 if got := weeksBetween(weekStart(tc.a), weekStart(tc.b)); got != 2 {
378 t.Fatalf("weeksBetween=%d, want 2", got)
379 }
380 })
381 }
382 }
383
384 // TestHeartbeatConfigSchemaVersionWritten: 新版本保存的配置必须带 schemaVersion=2,
385 // 供未来版本识别格式;旧配置(无字段)读取兼容且升级保存后带版本号。
386 func TestHeartbeatConfigSchemaVersionWritten(t *testing.T) {
387 isolateDesktopUserDirs(t)
388 engine := &HeartbeatEngine{}
389 if err := engine.saveTasks([]HeartbeatTask{{ID: "t1", Title: "task", Interval: "1h", Enabled: false}}); err != nil {
390 t.Fatalf("save: %v", err)
391 }
392 data, err := os.ReadFile(engine.configPath())
393 if err != nil {
394 t.Fatal(err)
395 }
396 var cfg heartbeatConfig
397 if err := json.Unmarshal(data, &cfg); err != nil {
398 t.Fatal(err)
399 }
400 if cfg.SchemaVersion != heartbeatSchemaVersion {
401 t.Fatalf("schemaVersion = %d, want %d", cfg.SchemaVersion, heartbeatSchemaVersion)
402 }
403 // 旧格式(无 schemaVersion)仍可读:模拟 v1 配置
404 legacy := `{"tasks":[{"id":"legacy","title":"L","interval":"1h","enabled":false}]}`
405 if err := os.WriteFile(engine.configPath(), []byte(legacy), 0o644); err != nil {
406 t.Fatal(err)
407 }
408 engine.ReloadTasks()
409 if err := engine.ReplaceTasks(engine.ListTasks()); err != nil {
410 t.Fatalf("legacy upgrade save: %v", err)
411 }
412 data, _ = os.ReadFile(engine.configPath())
413 _ = json.Unmarshal(data, &cfg)
414 if cfg.SchemaVersion != heartbeatSchemaVersion {
415 t.Fatalf("legacy upgrade schemaVersion = %d, want %d", cfg.SchemaVersion, heartbeatSchemaVersion)
416 }
417 }
418
419 // TestHeartbeatConfigForwardProtection: 未来版本(更高 schemaVersion)写入的配置,
420 // 当前二进制整表保存必须拒绝,不能静默降级覆盖 runHistory 等未来字段。
421 func TestHeartbeatConfigForwardProtection(t *testing.T) {
422 isolateDesktopUserDirs(t)
423 engine := &HeartbeatEngine{}
424 future := `{"schemaVersion":99,"tasks":[{"id":"f","title":"future","interval":"1h","enabled":false,"runHistory":[{"at":100,"topicId":"x"}]}]}`
425 if err := os.MkdirAll(filepath.Dir(engine.configPath()), 0o755); err != nil {
426 t.Fatal(err)
427 }
428 if err := os.WriteFile(engine.configPath(), []byte(future), 0o644); err != nil {
429 t.Fatal(err)
430 }
431 engine.ReloadTasks() // 读取成功(未知高版本不阻塞读取)
432 err := engine.ReplaceTasks([]HeartbeatTask{{ID: "f", Title: "edited", Interval: "2h", Enabled: true}})
433 if err == nil {
434 t.Fatal("ReplaceTasks on future-schema config must be refused")
435 }
436 // 磁盘内容未被覆盖
437 data, _ := os.ReadFile(engine.configPath())
438 if !bytes.Contains(data, []byte(`"schemaVersion":99`)) {
439 t.Fatalf("future config was overwritten: %s", data)
440 }
441 }
442
443 func TestCronDueDowSevenSundayAlias(t *testing.T) {
444 // "0 9 * * 7": 7 is the standard Sunday alias in the dow field — it must
445 // fire on Sundays (time.Weekday() == 0), not silently never match.
446 sunday := time.Date(2026, 8, 9, 9, 0, 0, 0, time.Local) // 2026-08-09 is a Sunday
447 if !cronDue("0 9 * * 7", sunday) {
448 t.Fatalf("Sunday 09:00 should match dow=7 (Sunday alias)")
449 }
450 // "0 9 * * 0,7": both Sunday spellings together
451 if !cronDue("0 9 * * 0,7", sunday) {
452 t.Fatalf("Sunday 09:00 should match dow=0,7")
453 }
454 // A non-Sunday must not match dow=7
455 monday := time.Date(2026, 8, 10, 9, 0, 0, 0, time.Local)
456 if cronDue("0 9 * * 7", monday) {
457 t.Fatalf("Monday should not match dow=7")
458 }
459 // "0 9 * * 6-7": dow range ending in 7 covers Sunday (6=Sat, 7=Sun)
460 if !cronDue("0 9 * * 6-7", sunday) {
461 t.Fatalf("Sunday should match dow range 6-7")
462 }
463 }
464
465 func TestIsCronExprFieldBounds(t *testing.T) {
466 // dom/month are 1-based: 0 can never match and must be rejected so the
467 // UI refuses the expression instead of silently scheduling a task that
468 // never fires (e.g. "0 0 0 * *" typed as "midnight every day").
469 rejected := []string{
470 "0 0 0 * *", // dom 0
471 "0 0 1 0 *", // month 0
472 "0 0 32 * *", // dom 32
473 "0 0 1 13 *", // month 13
474 "0 0 * 0-13 *", // month range with 0
475 "*/0 * * * *", // zero step never fires (minute % 0)
476 "0 0 5-1 * *", // descending range never matches
477 "0 60 * * *", // hour 60
478 "0 0 1 * 8", // dow 8 out of range
479 }
480 for _, expr := range rejected {
481 if isCronExpr(expr) {
482 t.Fatalf("isCronExpr(%q) should be false (out-of-bounds field)", expr)
483 }
484 }
485 accepted := []string{
486 "0 9 * * 7", // dow 7 is a valid Sunday alias
487 "0 9 1 1 0-7", // dow range 0-7 valid
488 "*/15 * * * *",
489 "0 9 1-31 * *",
490 "5-10/2 * * * *", // stepping range
491 }
492 for _, expr := range accepted {
493 if !isCronExpr(expr) {
494 t.Fatalf("isCronExpr(%q) should be true", expr)
495 }
496 }
497 }
498
499 // TestHeartbeatConfigForwardProtectionOnRead: 读侧也要拒绝更高 schema 的配置——
500 // 不能加载并按旧逻辑执行(调度/权限语义可能已变化)。此前只在写入侧拒绝。
501 func TestHeartbeatConfigForwardProtectionOnRead(t *testing.T) {
502 isolateDesktopUserDirs(t)
503 engine := &HeartbeatEngine{}
504 future := `{"schemaVersion":99,"tasks":[{"id":"f","title":"future","interval":"1h","enabled":false}]}`
505 if err := os.MkdirAll(filepath.Dir(engine.configPath()), 0o755); err != nil {
506 t.Fatal(err)
507 }
508 if err := os.WriteFile(engine.configPath(), []byte(future), 0o644); err != nil {
509 t.Fatal(err)
510 }
511 if _, err := engine.readConfigSnapshot(); err == nil {
512 t.Fatal("readConfigSnapshot should reject a future schemaVersion")
513 }
514 if tasks := engine.loadTasks(); tasks != nil {
515 t.Fatal("loadTasks should refuse to load a future schemaVersion")
516 }
517 }
518
518 lines GO