mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
953 lines
21 KiB
Go
953 lines
21 KiB
Go
package tikistore
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
taskpkg "github.com/boolean-maybe/tiki/task"
|
|
)
|
|
|
|
func TestSortTasks(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
tasks []*taskpkg.Task
|
|
expected []string // expected order of IDs
|
|
}{
|
|
{
|
|
name: "sort by priority first, then title",
|
|
tasks: []*taskpkg.Task{
|
|
{ID: "TIKI-abc123", Title: "Zebra Task", Priority: 2},
|
|
{ID: "TIKI-def456", Title: "Alpha Task", Priority: 1},
|
|
{ID: "TIKI-ghi789", Title: "Beta Task", Priority: 1},
|
|
},
|
|
expected: []string{"TIKI-def456", "TIKI-ghi789", "TIKI-abc123"}, // Alpha, Beta (both P1), then Zebra (P2)
|
|
},
|
|
{
|
|
name: "same priority - alphabetical by title",
|
|
tasks: []*taskpkg.Task{
|
|
{ID: "TIKI-abc10z", Title: "Zebra", Priority: 3},
|
|
{ID: "TIKI-abc2zz", Title: "Apple", Priority: 3},
|
|
{ID: "TIKI-abc1zz", Title: "Mango", Priority: 3},
|
|
},
|
|
expected: []string{"TIKI-abc2zz", "TIKI-abc1zz", "TIKI-abc10z"}, // Apple, Mango, Zebra
|
|
},
|
|
{
|
|
name: "same priority and title - tiebreak by ID",
|
|
tasks: []*taskpkg.Task{
|
|
{ID: "TIKI-ccc333", Title: "Same", Priority: 2},
|
|
{ID: "TIKI-aaa111", Title: "Same", Priority: 2},
|
|
{ID: "TIKI-bbb222", Title: "Same", Priority: 2},
|
|
},
|
|
expected: []string{"TIKI-aaa111", "TIKI-bbb222", "TIKI-ccc333"},
|
|
},
|
|
{
|
|
name: "empty task list",
|
|
tasks: []*taskpkg.Task{},
|
|
expected: []string{},
|
|
},
|
|
{
|
|
name: "single task",
|
|
tasks: []*taskpkg.Task{
|
|
{ID: "TIKI-abc1zz", Title: "Only Task", Priority: 3},
|
|
},
|
|
expected: []string{"TIKI-abc1zz"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
sortTasks(tt.tasks)
|
|
|
|
if len(tt.tasks) != len(tt.expected) {
|
|
t.Fatalf("task count = %d, want %d", len(tt.tasks), len(tt.expected))
|
|
}
|
|
|
|
for i, task := range tt.tasks {
|
|
if task.ID != tt.expected[i] {
|
|
t.Errorf("tasks[%d].ID = %q, want %q", i, task.ID, tt.expected[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSearch_MatchesTaskID(t *testing.T) {
|
|
store := &TikiStore{
|
|
tasks: map[string]*taskpkg.Task{
|
|
"TIKI-ABC123": {
|
|
ID: "TIKI-ABC123",
|
|
Title: "Unrelated Title",
|
|
Status: taskpkg.StatusBacklog,
|
|
Priority: 1,
|
|
},
|
|
"TIKI-DEF456": {
|
|
ID: "TIKI-DEF456",
|
|
Title: "Another Title",
|
|
Status: taskpkg.StatusReady,
|
|
Priority: 2,
|
|
},
|
|
},
|
|
}
|
|
|
|
results := store.Search("abc123", nil)
|
|
if len(results) != 1 {
|
|
t.Fatalf("result count = %d, want 1", len(results))
|
|
}
|
|
if results[0].Task.ID != "TIKI-ABC123" {
|
|
t.Errorf("results[0].Task.ID = %q, want %q", results[0].Task.ID, "TIKI-ABC123")
|
|
}
|
|
}
|
|
|
|
func TestSearch_AllTasksIncludesDescription(t *testing.T) {
|
|
store := &TikiStore{
|
|
tasks: map[string]*taskpkg.Task{
|
|
"TIKI-aaa111": {
|
|
ID: "TIKI-aaa111",
|
|
Title: "Alpha Task",
|
|
Description: "Contains the keyword needle",
|
|
Status: taskpkg.StatusBacklog,
|
|
Priority: 2,
|
|
},
|
|
"TIKI-bbb222": {
|
|
ID: "TIKI-bbb222",
|
|
Title: "Beta Task",
|
|
Description: "No match here",
|
|
Status: taskpkg.StatusReady,
|
|
Priority: 1,
|
|
},
|
|
"TIKI-ccc333": {
|
|
ID: "TIKI-ccc333",
|
|
Title: "Gamma Task",
|
|
Description: "Another needle appears",
|
|
Status: taskpkg.StatusReview,
|
|
Priority: 3,
|
|
},
|
|
},
|
|
}
|
|
|
|
results := store.Search("needle", nil)
|
|
if len(results) != 2 {
|
|
t.Fatalf("result count = %d, want 2", len(results))
|
|
}
|
|
|
|
expectedIDs := []string{"TIKI-aaa111", "TIKI-ccc333"} // sorted by priority then title
|
|
for i, result := range results {
|
|
if result.Task.ID != expectedIDs[i] {
|
|
t.Errorf("results[%d].Task.ID = %q, want %q", i, result.Task.ID, expectedIDs[i])
|
|
}
|
|
if result.Score != 1.0 {
|
|
t.Errorf("results[%d].Score = %f, want 1.0", i, result.Score)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSearch_MatchesTags(t *testing.T) {
|
|
store := &TikiStore{
|
|
tasks: map[string]*taskpkg.Task{
|
|
"TIKI-TAG001": {
|
|
ID: "TIKI-TAG001",
|
|
Title: "Tagged Task",
|
|
Status: taskpkg.StatusBacklog,
|
|
Priority: 1,
|
|
Tags: []string{"frontend", "ui"},
|
|
},
|
|
"TIKI-TAG002": {
|
|
ID: "TIKI-TAG002",
|
|
Title: "Untagged Task",
|
|
Status: taskpkg.StatusReady,
|
|
Priority: 2,
|
|
},
|
|
},
|
|
}
|
|
|
|
results := store.Search("frontend", nil)
|
|
if len(results) != 1 {
|
|
t.Fatalf("result count = %d, want 1", len(results))
|
|
}
|
|
if results[0].Task.ID != "TIKI-TAG001" {
|
|
t.Errorf("results[0].Task.ID = %q, want %q", results[0].Task.ID, "TIKI-TAG001")
|
|
}
|
|
|
|
results = store.Search("backend", nil)
|
|
if len(results) != 0 {
|
|
t.Fatalf("result count = %d, want 0", len(results))
|
|
}
|
|
}
|
|
|
|
func TestLoadTaskFile_DependsOn(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
tests := []struct {
|
|
name string
|
|
fileContent string
|
|
expectedDependsOn []string
|
|
shouldLoad bool
|
|
}{
|
|
{
|
|
name: "valid dependsOn list",
|
|
fileContent: `---
|
|
title: Test Task
|
|
type: story
|
|
status: backlog
|
|
dependsOn:
|
|
- TIKI-ABC123
|
|
- TIKI-DEF456
|
|
---
|
|
Task description`,
|
|
expectedDependsOn: []string{"TIKI-ABC123", "TIKI-DEF456"},
|
|
shouldLoad: true,
|
|
},
|
|
{
|
|
name: "lowercase IDs uppercased",
|
|
fileContent: `---
|
|
title: Test Task
|
|
type: story
|
|
status: backlog
|
|
dependsOn:
|
|
- tiki-abc123
|
|
---
|
|
Task description`,
|
|
expectedDependsOn: []string{"TIKI-ABC123"},
|
|
shouldLoad: true,
|
|
},
|
|
{
|
|
name: "missing dependsOn field",
|
|
fileContent: `---
|
|
title: Test Task
|
|
type: story
|
|
status: backlog
|
|
---
|
|
Task description`,
|
|
expectedDependsOn: []string{},
|
|
shouldLoad: true,
|
|
},
|
|
{
|
|
name: "empty dependsOn array",
|
|
fileContent: `---
|
|
title: Test Task
|
|
type: story
|
|
status: backlog
|
|
dependsOn: []
|
|
---
|
|
Task description`,
|
|
expectedDependsOn: []string{},
|
|
shouldLoad: true,
|
|
},
|
|
{
|
|
name: "invalid dependsOn - scalar",
|
|
fileContent: `---
|
|
title: Test Task
|
|
type: story
|
|
status: backlog
|
|
dependsOn: not-a-list
|
|
---
|
|
Task description`,
|
|
expectedDependsOn: []string{},
|
|
shouldLoad: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
testFile := tmpDir + "/test-task.md"
|
|
err := os.WriteFile(testFile, []byte(tt.fileContent), 0644)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create test file: %v", err)
|
|
}
|
|
|
|
store, storeErr := NewTikiStore(tmpDir)
|
|
if storeErr != nil {
|
|
t.Fatalf("Failed to create TikiStore: %v", storeErr)
|
|
}
|
|
|
|
task, err := store.loadTaskFile(testFile, nil, nil)
|
|
|
|
if tt.shouldLoad {
|
|
if err != nil {
|
|
t.Fatalf("loadTaskFile() unexpected error = %v", err)
|
|
}
|
|
if task == nil {
|
|
t.Fatal("loadTaskFile() returned nil task")
|
|
}
|
|
|
|
if !reflect.DeepEqual(task.DependsOn, tt.expectedDependsOn) {
|
|
t.Errorf("task.DependsOn = %v, expected %v", task.DependsOn, tt.expectedDependsOn)
|
|
}
|
|
} else {
|
|
if err == nil {
|
|
t.Error("loadTaskFile() expected error but got none")
|
|
}
|
|
}
|
|
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadTaskFile_InvalidTags(t *testing.T) {
|
|
// Create temporary directory for test files
|
|
tmpDir := t.TempDir()
|
|
|
|
tests := []struct {
|
|
name string
|
|
fileContent string
|
|
expectedTags []string
|
|
shouldLoad bool
|
|
}{
|
|
{
|
|
name: "valid tags list",
|
|
fileContent: `---
|
|
title: Test Task
|
|
type: story
|
|
status: backlog
|
|
tags:
|
|
- frontend
|
|
- backend
|
|
---
|
|
Task description`,
|
|
expectedTags: []string{"frontend", "backend"},
|
|
shouldLoad: true,
|
|
},
|
|
{
|
|
name: "invalid tags - scalar string",
|
|
fileContent: `---
|
|
title: Test Task
|
|
type: story
|
|
status: backlog
|
|
tags: not-a-list
|
|
---
|
|
Task description`,
|
|
expectedTags: []string{},
|
|
shouldLoad: true,
|
|
},
|
|
{
|
|
name: "invalid tags - number",
|
|
fileContent: `---
|
|
title: Test Task
|
|
type: story
|
|
status: backlog
|
|
tags: 123
|
|
---
|
|
Task description`,
|
|
expectedTags: []string{},
|
|
shouldLoad: true,
|
|
},
|
|
{
|
|
name: "invalid tags - boolean",
|
|
fileContent: `---
|
|
title: Test Task
|
|
type: story
|
|
status: backlog
|
|
tags: true
|
|
---
|
|
Task description`,
|
|
expectedTags: []string{},
|
|
shouldLoad: true,
|
|
},
|
|
{
|
|
name: "invalid tags - object",
|
|
fileContent: `---
|
|
title: Test Task
|
|
type: story
|
|
status: backlog
|
|
tags:
|
|
key: value
|
|
---
|
|
Task description`,
|
|
expectedTags: []string{},
|
|
shouldLoad: true,
|
|
},
|
|
{
|
|
name: "missing tags field",
|
|
fileContent: `---
|
|
title: Test Task
|
|
type: story
|
|
status: backlog
|
|
---
|
|
Task description`,
|
|
expectedTags: []string{},
|
|
shouldLoad: true,
|
|
},
|
|
{
|
|
name: "empty tags array",
|
|
fileContent: `---
|
|
title: Test Task
|
|
type: story
|
|
status: backlog
|
|
tags: []
|
|
---
|
|
Task description`,
|
|
expectedTags: []string{},
|
|
shouldLoad: true,
|
|
},
|
|
{
|
|
name: "tags with empty strings filtered",
|
|
fileContent: `---
|
|
title: Test Task
|
|
type: story
|
|
status: backlog
|
|
tags:
|
|
- frontend
|
|
- ""
|
|
- backend
|
|
---
|
|
Task description`,
|
|
expectedTags: []string{"frontend", "backend"},
|
|
shouldLoad: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Create test file
|
|
testFile := tmpDir + "/test-task.md"
|
|
err := os.WriteFile(testFile, []byte(tt.fileContent), 0644)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create test file: %v", err)
|
|
}
|
|
|
|
// Create TikiStore
|
|
store, storeErr := NewTikiStore(tmpDir)
|
|
if storeErr != nil {
|
|
t.Fatalf("Failed to create TikiStore: %v", storeErr)
|
|
}
|
|
|
|
// Load the task file directly
|
|
task, err := store.loadTaskFile(testFile, nil, nil)
|
|
|
|
if tt.shouldLoad {
|
|
if err != nil {
|
|
t.Fatalf("loadTaskFile() unexpected error = %v", err)
|
|
}
|
|
if task == nil {
|
|
t.Fatal("loadTaskFile() returned nil task")
|
|
}
|
|
|
|
// Verify tags
|
|
if !reflect.DeepEqual(task.Tags, tt.expectedTags) {
|
|
t.Errorf("task.Tags = %v, expected %v", task.Tags, tt.expectedTags)
|
|
}
|
|
|
|
// Verify other fields still work
|
|
if task.Title != "Test Task" {
|
|
t.Errorf("task.Title = %q, expected %q", task.Title, "Test Task")
|
|
}
|
|
if task.Type != taskpkg.TypeStory {
|
|
t.Errorf("task.Type = %q, expected %q", task.Type, taskpkg.TypeStory)
|
|
}
|
|
if task.Status != taskpkg.StatusBacklog {
|
|
t.Errorf("task.Status = %q, expected %q", task.Status, taskpkg.StatusBacklog)
|
|
}
|
|
} else {
|
|
if err == nil {
|
|
t.Error("loadTaskFile() expected error but got none")
|
|
}
|
|
}
|
|
|
|
// Clean up test file
|
|
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadTaskFile_Due(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
tests := []struct {
|
|
name string
|
|
fileContent string
|
|
expectZero bool
|
|
expectValue string // YYYY-MM-DD format if not zero
|
|
shouldLoad bool
|
|
}{
|
|
{
|
|
name: "valid due date",
|
|
fileContent: `---
|
|
title: Test Task
|
|
type: story
|
|
status: backlog
|
|
due: 2026-03-16
|
|
---
|
|
Task description`,
|
|
expectZero: false,
|
|
expectValue: "2026-03-16",
|
|
shouldLoad: true,
|
|
},
|
|
{
|
|
name: "valid due date with quotes",
|
|
fileContent: `---
|
|
title: Test Task
|
|
type: story
|
|
status: backlog
|
|
due: '2026-03-16'
|
|
---
|
|
Task description`,
|
|
expectZero: false,
|
|
expectValue: "2026-03-16",
|
|
shouldLoad: true,
|
|
},
|
|
{
|
|
name: "missing due field",
|
|
fileContent: `---
|
|
title: Test Task
|
|
type: story
|
|
status: backlog
|
|
---
|
|
Task description`,
|
|
expectZero: true,
|
|
shouldLoad: true,
|
|
},
|
|
{
|
|
name: "empty due field",
|
|
fileContent: `---
|
|
title: Test Task
|
|
type: story
|
|
status: backlog
|
|
due: ''
|
|
---
|
|
Task description`,
|
|
expectZero: true,
|
|
shouldLoad: true,
|
|
},
|
|
{
|
|
name: "invalid due date format",
|
|
fileContent: `---
|
|
title: Test Task
|
|
type: story
|
|
status: backlog
|
|
due: 03/16/2026
|
|
---
|
|
Task description`,
|
|
expectZero: true, // Should default to zero
|
|
shouldLoad: true,
|
|
},
|
|
{
|
|
name: "invalid due date - number",
|
|
fileContent: `---
|
|
title: Test Task
|
|
type: story
|
|
status: backlog
|
|
due: 20260316
|
|
---
|
|
Task description`,
|
|
expectZero: true, // Should default to zero
|
|
shouldLoad: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Create task file
|
|
testFile := filepath.Join(tmpDir, "TIKI-TEST01.md")
|
|
if err := os.WriteFile(testFile, []byte(tt.fileContent), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
// Load store
|
|
store, err := NewTikiStore(tmpDir)
|
|
if err != nil {
|
|
t.Fatalf("NewTikiStore() error = %v", err)
|
|
}
|
|
|
|
// Get task
|
|
task := store.GetTask("TIKI-TEST01")
|
|
if !tt.shouldLoad {
|
|
if task != nil {
|
|
t.Error("expected nil task, but got one")
|
|
}
|
|
return
|
|
}
|
|
|
|
if task == nil {
|
|
t.Fatal("GetTask() returned nil")
|
|
return
|
|
}
|
|
|
|
if tt.expectZero {
|
|
if !task.Due.IsZero() {
|
|
t.Errorf("expected zero due time, got = %v", task.Due)
|
|
}
|
|
} else {
|
|
if task.Due.IsZero() {
|
|
t.Error("expected non-zero due time, got zero")
|
|
}
|
|
got := task.Due.Format(taskpkg.DateFormat)
|
|
if got != tt.expectValue {
|
|
t.Errorf("due date got = %v, expected %v", got, tt.expectValue)
|
|
}
|
|
}
|
|
|
|
// Clean up test file
|
|
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadTaskFile_Recurrence(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
tests := []struct {
|
|
name string
|
|
fileContent string
|
|
expectValue taskpkg.Recurrence
|
|
shouldLoad bool
|
|
}{
|
|
{
|
|
name: "valid recurrence daily",
|
|
fileContent: `---
|
|
title: Test Task
|
|
type: story
|
|
status: backlog
|
|
recurrence: "0 0 * * *"
|
|
---
|
|
Task description`,
|
|
expectValue: taskpkg.RecurrenceDaily,
|
|
shouldLoad: true,
|
|
},
|
|
{
|
|
name: "valid recurrence weekly",
|
|
fileContent: `---
|
|
title: Test Task
|
|
type: story
|
|
status: backlog
|
|
recurrence: "0 0 * * MON"
|
|
---
|
|
Task description`,
|
|
expectValue: "0 0 * * MON",
|
|
shouldLoad: true,
|
|
},
|
|
{
|
|
name: "missing recurrence field",
|
|
fileContent: `---
|
|
title: Test Task
|
|
type: story
|
|
status: backlog
|
|
---
|
|
Task description`,
|
|
expectValue: taskpkg.RecurrenceNone,
|
|
shouldLoad: true,
|
|
},
|
|
{
|
|
name: "invalid recurrence defaults to empty",
|
|
fileContent: `---
|
|
title: Test Task
|
|
type: story
|
|
status: backlog
|
|
recurrence: "every tuesday"
|
|
---
|
|
Task description`,
|
|
expectValue: taskpkg.RecurrenceNone,
|
|
shouldLoad: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
testFile := filepath.Join(tmpDir, "TIKI-REC001.md")
|
|
if err := os.WriteFile(testFile, []byte(tt.fileContent), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
store, err := NewTikiStore(tmpDir)
|
|
if err != nil {
|
|
t.Fatalf("NewTikiStore() error = %v", err)
|
|
}
|
|
|
|
task := store.GetTask("TIKI-REC001")
|
|
if !tt.shouldLoad {
|
|
if task != nil {
|
|
t.Error("expected nil task, but got one")
|
|
}
|
|
return
|
|
}
|
|
|
|
if task == nil {
|
|
t.Fatal("GetTask() returned nil")
|
|
return
|
|
}
|
|
|
|
if task.Recurrence != tt.expectValue {
|
|
t.Errorf("recurrence got = %q, expected %q", task.Recurrence, tt.expectValue)
|
|
}
|
|
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSaveTask_Recurrence(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
store, err := NewTikiStore(tmpDir)
|
|
if err != nil {
|
|
t.Fatalf("NewTikiStore() error = %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
recurrence taskpkg.Recurrence
|
|
expectInFM bool
|
|
}{
|
|
{
|
|
name: "with recurrence",
|
|
recurrence: "0 0 * * MON",
|
|
expectInFM: true,
|
|
},
|
|
{
|
|
name: "without recurrence",
|
|
recurrence: taskpkg.RecurrenceNone,
|
|
expectInFM: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
task := &taskpkg.Task{
|
|
ID: "TIKI-RECSVR",
|
|
Title: "Test Save Recurrence",
|
|
Type: taskpkg.TypeStory,
|
|
Status: "backlog",
|
|
Priority: 3,
|
|
Recurrence: tt.recurrence,
|
|
Description: "Test description",
|
|
}
|
|
|
|
if err := store.CreateTask(task); err != nil {
|
|
t.Fatalf("CreateTask() error = %v", err)
|
|
}
|
|
|
|
filePath := filepath.Join(tmpDir, "tiki-recsvr.md")
|
|
content, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
t.Fatalf("failed to read saved file: %v", err)
|
|
}
|
|
|
|
fileStr := string(content)
|
|
hasRecLine := strings.Contains(fileStr, "recurrence:")
|
|
|
|
if tt.expectInFM && !hasRecLine {
|
|
t.Errorf("expected 'recurrence:' in frontmatter, but not found.\nFile content:\n%s", fileStr)
|
|
}
|
|
if !tt.expectInFM && hasRecLine {
|
|
t.Errorf("did not expect 'recurrence:' in frontmatter (omitempty), but found.\nFile content:\n%s", fileStr)
|
|
}
|
|
|
|
// Verify round-trip
|
|
loaded := store.GetTask("TIKI-RECSVR")
|
|
if loaded == nil {
|
|
t.Fatal("GetTask() returned nil")
|
|
return
|
|
}
|
|
if loaded.Recurrence != task.Recurrence {
|
|
t.Errorf("round-trip failed: saved %q, loaded %q", task.Recurrence, loaded.Recurrence)
|
|
}
|
|
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMatchesQuery(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
task *taskpkg.Task
|
|
query string
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "nil task returns false",
|
|
task: nil,
|
|
query: "foo",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "empty query returns false",
|
|
task: &taskpkg.Task{ID: "TIKI-MQ0001", Title: "Hello"},
|
|
query: "",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "match by ID case-insensitive",
|
|
task: &taskpkg.Task{ID: "TIKI-ABC123"},
|
|
query: "tiki-abc",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "match by title",
|
|
task: &taskpkg.Task{ID: "TIKI-MQ0002", Title: "Hello World"},
|
|
query: "hello",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "match by description",
|
|
task: &taskpkg.Task{ID: "TIKI-MQ0003", Description: "some text here"},
|
|
query: "some",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "match by first tag",
|
|
task: &taskpkg.Task{ID: "TIKI-MQ0004", Tags: []string{"frontend"}},
|
|
query: "frontend",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "match by second tag",
|
|
task: &taskpkg.Task{ID: "TIKI-MQ0005", Tags: []string{"a", "backend"}},
|
|
query: "backend",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "no match",
|
|
task: &taskpkg.Task{ID: "TIKI-X", Title: "foo"},
|
|
query: "zzz",
|
|
expected: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := matchesQuery(tt.task, tt.query)
|
|
if got != tt.expected {
|
|
t.Errorf("matchesQuery() = %v, want %v", got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSearch_WithFilterFunc(t *testing.T) {
|
|
buildStore := func() *TikiStore {
|
|
return &TikiStore{
|
|
tasks: map[string]*taskpkg.Task{
|
|
"TIKI-F00001": {ID: "TIKI-F00001", Title: "Alpha", Priority: 1},
|
|
"TIKI-F00002": {ID: "TIKI-F00002", Title: "Beta", Priority: 2},
|
|
"TIKI-F00003": {ID: "TIKI-F00003", Title: "Gamma", Priority: 3},
|
|
},
|
|
}
|
|
}
|
|
|
|
t.Run("filter excludes all returns empty", func(t *testing.T) {
|
|
s := buildStore()
|
|
results := s.Search("", func(*taskpkg.Task) bool { return false })
|
|
if len(results) != 0 {
|
|
t.Errorf("got %d results, want 0", len(results))
|
|
}
|
|
})
|
|
|
|
t.Run("filter includes subset returns subset", func(t *testing.T) {
|
|
s := buildStore()
|
|
results := s.Search("", func(t *taskpkg.Task) bool { return t.ID == "TIKI-F00001" })
|
|
if len(results) != 1 {
|
|
t.Errorf("got %d results, want 1", len(results))
|
|
}
|
|
if results[0].Task.ID != "TIKI-F00001" {
|
|
t.Errorf("got ID %q, want TIKI-F00001", results[0].Task.ID)
|
|
}
|
|
})
|
|
|
|
t.Run("filter + query intersection", func(t *testing.T) {
|
|
s := buildStore()
|
|
// filter allows F00001 and F00002, query matches only "Beta"
|
|
results := s.Search("beta", func(t *taskpkg.Task) bool {
|
|
return t.ID == "TIKI-F00001" || t.ID == "TIKI-F00002"
|
|
})
|
|
if len(results) != 1 {
|
|
t.Errorf("got %d results, want 1", len(results))
|
|
}
|
|
if results[0].Task.ID != "TIKI-F00002" {
|
|
t.Errorf("got ID %q, want TIKI-F00002", results[0].Task.ID)
|
|
}
|
|
})
|
|
|
|
t.Run("nil filter + empty query returns all tasks", func(t *testing.T) {
|
|
s := &TikiStore{
|
|
tasks: map[string]*taskpkg.Task{
|
|
"TIKI-G00001": {ID: "TIKI-G00001", Title: "One"},
|
|
"TIKI-G00002": {ID: "TIKI-G00002", Title: "Two"},
|
|
},
|
|
}
|
|
results := s.Search("", nil)
|
|
if len(results) != 2 {
|
|
t.Errorf("got %d results, want 2", len(results))
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSaveTask_Due(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
store, err := NewTikiStore(tmpDir)
|
|
if err != nil {
|
|
t.Fatalf("NewTikiStore() error = %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
dueValue string // YYYY-MM-DD or empty
|
|
expectInFM bool // should appear in frontmatter
|
|
}{
|
|
{
|
|
name: "with due date",
|
|
dueValue: "2026-03-16",
|
|
expectInFM: true,
|
|
},
|
|
{
|
|
name: "without due date",
|
|
dueValue: "",
|
|
expectInFM: false, // omitempty should exclude it
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Create task
|
|
var dueTime time.Time
|
|
if tt.dueValue != "" {
|
|
dueTime, _ = time.Parse(taskpkg.DateFormat, tt.dueValue)
|
|
}
|
|
|
|
task := &taskpkg.Task{
|
|
ID: "TIKI-SAVE01",
|
|
Title: "Test Save",
|
|
Type: taskpkg.TypeStory,
|
|
Status: "backlog",
|
|
Priority: 3,
|
|
Due: dueTime,
|
|
Description: "Test description",
|
|
}
|
|
|
|
// Save task
|
|
if err := store.CreateTask(task); err != nil {
|
|
t.Fatalf("CreateTask() error = %v", err)
|
|
}
|
|
|
|
// Read file and check frontmatter
|
|
filePath := filepath.Join(tmpDir, "tiki-save01.md")
|
|
content, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
t.Fatalf("failed to read saved file: %v", err)
|
|
}
|
|
|
|
fileStr := string(content)
|
|
hasDueLine := strings.Contains(fileStr, "due:")
|
|
|
|
if tt.expectInFM && !hasDueLine {
|
|
t.Errorf("expected 'due:' in frontmatter, but not found.\nFile content:\n%s", fileStr)
|
|
}
|
|
if !tt.expectInFM && hasDueLine {
|
|
t.Errorf("did not expect 'due:' in frontmatter (omitempty), but found.\nFile content:\n%s", fileStr)
|
|
}
|
|
|
|
// Verify round-trip
|
|
loaded := store.GetTask("TIKI-SAVE01")
|
|
if loaded == nil {
|
|
t.Fatal("GetTask() returned nil")
|
|
}
|
|
|
|
if !loaded.Due.Equal(task.Due) {
|
|
t.Errorf("round-trip failed: saved %v, loaded %v", task.Due, loaded.Due)
|
|
}
|
|
|
|
// Clean up
|
|
|
|
})
|
|
}
|
|
}
|