// markdown_store_test.go 验证 MarkdownStore 的持久化 + round-trip + 并发安全. package tasklist import ( "context" "errors" "os" "path/filepath" "strings" "sync" "testing" ) // newTempStore 返回一个绑定到临时路径的 MarkdownStore. func newTempStore(t *testing.T) *MarkdownStore { t.Helper() dir := t.TempDir() return NewMarkdownStore(filepath.Join(dir, "tasks.md")) } func TestMarkdownStore_AddAndGet(t *testing.T) { ctx := context.Background() tl := New(newTempStore(t)) task, err := tl.Add(ctx, "写单测", "覆盖边界情况") if err != nil { t.Fatalf("Add: %v", err) } got, err := tl.Get(ctx, task.ID) if err != nil { t.Fatalf("Get: %v", err) } if got.Subject != "写单测" { t.Errorf("subject mismatch: %s", got.Subject) } if got.Description != "覆盖边界情况" { t.Errorf("description mismatch: %s", got.Description) } if got.Version != 1 { t.Errorf("version mismatch: %d", got.Version) } } func TestMarkdownStore_PersistsAcrossInstances(t *testing.T) { ctx := context.Background() dir := t.TempDir() path := filepath.Join(dir, "tasks.md") // 第一个实例: 写入 store1 := NewMarkdownStore(path) tl1 := New(store1) task, err := tl1.Add(ctx, "跨实例持久化", "") if err != nil { t.Fatalf("Add: %v", err) } _, _ = tl1.Claim(ctx, task.ID, "worker-1") tl1.Close() // 第二个实例: 读取 store2 := NewMarkdownStore(path) tl2 := New(store2) defer tl2.Close() got, err := tl2.Get(ctx, task.ID) if err != nil { t.Fatalf("Get from second instance: %v", err) } if got.Subject != "跨实例持久化" { t.Errorf("subject mismatch: %s", got.Subject) } if got.Status != StatusClaimed { t.Errorf("status mismatch: %s", got.Status) } if got.ClaimedBy != "worker-1" { t.Errorf("claimed_by mismatch: %s", got.ClaimedBy) } } func TestMarkdownStore_RoundTrip(t *testing.T) { ctx := context.Background() tl := New(newTempStore(t)) task, _ := tl.Add(ctx, "round-trip 主题", "含空格 和 换行\n第二行") claimed, _ := tl.Claim(ctx, task.ID, "worker-A") completed, err := tl.Complete(ctx, claimed.ID, "结果含空格\n和换行") if err != nil { t.Fatalf("Complete: %v", err) } got, err := tl.Get(ctx, task.ID) if err != nil { t.Fatalf("Get: %v", err) } if got.Description != "含空格 和 换行\n第二行" { t.Errorf("description round-trip failed: %q", got.Description) } if got.Result != "结果含空格\n和换行" { t.Errorf("result round-trip failed: %q", got.Result) } if got.Status != StatusCompleted { t.Errorf("status mismatch: %s", got.Status) } if got.Version != completed.Version { t.Errorf("version mismatch: %d vs %d", got.Version, completed.Version) } } func TestMarkdownStore_ConcurrentClaim(t *testing.T) { ctx := context.Background() tl := New(newTempStore(t)) task, _ := tl.Add(ctx, "并发测试", "") const N = 20 var wg sync.WaitGroup var success int var successMu sync.Mutex for i := range N { wg.Add(1) go func(idx int) { defer wg.Done() _, err := tl.Claim(ctx, task.ID, "worker-"+string(rune('A'+idx%26))) if err == nil { successMu.Lock() success++ successMu.Unlock() } }(i) } wg.Wait() if success != 1 { t.Errorf("expected exactly 1 successful claim, got %d", success) } } func TestMarkdownStore_HumanReadable(t *testing.T) { ctx := context.Background() dir := t.TempDir() path := filepath.Join(dir, "tasks.md") store := NewMarkdownStore(path) tl := New(store) task1, _ := tl.Add(ctx, "task one", "desc one") task2, _ := tl.Add(ctx, "task two", "desc two") _, _ = tl.Claim(ctx, task2.ID, "worker-42") raw, err := os.ReadFile(path) if err != nil { t.Fatalf("read: %v", err) } content := string(raw) // 人类可读的 checkbox if !strings.Contains(content, "- [ ] task one") { t.Errorf("missing pending checkbox: %s", content) } if !strings.Contains(content, "- [~] task two") { t.Errorf("missing claimed checkbox (~): %s", content) } // HTML 注释 meta if !strings.Contains(content, "task:"+task1.ID) { t.Errorf("missing task1 meta id: %s", content) } if !strings.Contains(content, "claimed_by:worker-42") { t.Errorf("missing claimed_by meta: %s", content) } if !strings.Contains(content, "status:pending") { t.Errorf("missing pending status meta: %s", content) } if !strings.Contains(content, "status:claimed") { t.Errorf("missing claimed status meta: %s", content) } // 标题提示 if !strings.Contains(content, "# Team Shared Tasks") { t.Errorf("missing header: %s", content) } } func TestMarkdownStore_GetMissingReturnsErrNotFound(t *testing.T) { ctx := context.Background() tl := New(newTempStore(t)) if _, err := tl.Get(ctx, "not-exist"); !errors.Is(err, ErrTaskNotFound) { t.Errorf("expected ErrTaskNotFound, got %v", err) } } func TestMarkdownStore_ListEmptyFileReturnsEmpty(t *testing.T) { ctx := context.Background() tl := New(newTempStore(t)) all, err := tl.List(ctx) if err != nil { t.Fatalf("List: %v", err) } if len(all) != 0 { t.Errorf("expected empty, got %d tasks", len(all)) } } // TestMarkdownStore_ImportsHandwrittenCheckbox 验证手写格式 (只有 checkbox 无 meta) // 也能被导入为 task 记录 (兼容 Anthropic 手工编辑 / 初始模板). func TestMarkdownStore_ImportsHandwrittenCheckbox(t *testing.T) { ctx := context.Background() dir := t.TempDir() path := filepath.Join(dir, "tasks.md") // 预写一份 "Anthropic 手工风格" 文件 handwritten := `# Team Shared Tasks - [ ] 手写的任务一 - [x] 已完成的任务二 ` if err := os.WriteFile(path, []byte(handwritten), 0o644); err != nil { t.Fatalf("WriteFile: %v", err) } store := NewMarkdownStore(path) tl := New(store) all, err := tl.List(ctx) if err != nil { t.Fatalf("List: %v", err) } if len(all) != 2 { t.Errorf("expected 2 imported tasks, got %d", len(all)) } var foundPending, foundCompleted bool for _, t := range all { if t.Subject == "手写的任务一" && t.Status == StatusPending { foundPending = true } if t.Subject == "已完成的任务二" && t.Status == StatusCompleted { foundCompleted = true } } if !foundPending { t.Error("pending handwritten task not imported") } if !foundCompleted { t.Error("completed handwritten task not imported") } }