T02: Task loader — parsiranje markdown taskova
- Task struct sa svim poljima (ID, Title, Status, Agent, Model, DependsOn, Description) - LoadTask, ScanTasks, FindTask, NextTask, MoveTask - 17 testova — svi prolaze Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ceb7f9ecfa
commit
79bcd52076
54
TASKS/reports/T02-report.md
Normal file
54
TASKS/reports/T02-report.md
Normal file
@ -0,0 +1,54 @@
|
||||
# T02 Izveštaj: Task loader — parsiranje markdown taskova
|
||||
|
||||
**Agent:** coder
|
||||
**Model:** Opus
|
||||
**Datum:** 2026-02-20
|
||||
|
||||
---
|
||||
|
||||
## Šta je urađeno
|
||||
|
||||
Implementiran task loader u `code/internal/supervisor/`:
|
||||
|
||||
### Kreirani fajlovi
|
||||
|
||||
| Fajl | Opis |
|
||||
|------|------|
|
||||
| `task.go` | Task struct, LoadTask, ScanTasks, FindTask, NextTask, MoveTask |
|
||||
| `task_test.go` | 17 testova za sve funkcije i edge cases |
|
||||
|
||||
### Funkcije
|
||||
|
||||
- **LoadTask** — parsira markdown task fajl (ID, Title, Agent, Model, DependsOn, Description, Status iz foldera)
|
||||
- **ScanTasks** — skenira sve status foldere, vraća listu svih taskova
|
||||
- **FindTask** — traži task po ID-u
|
||||
- **NextTask** — prvi ready/ task čije su zavisnosti sve u done/
|
||||
- **MoveTask** — premešta task fajl između foldera
|
||||
|
||||
### Testovi — 17/17 PASS
|
||||
|
||||
```
|
||||
TestLoadTask_ParsesAllFields PASS
|
||||
TestLoadTask_WithDependencies PASS
|
||||
TestLoadTask_WithCheckmarkDependencies PASS
|
||||
TestLoadTask_NoID PASS
|
||||
TestLoadTask_NonexistentFile PASS
|
||||
TestScanTasks_FindsAllFolders PASS
|
||||
TestScanTasks_EmptyFolder PASS
|
||||
TestScanTasks_MissingFolders PASS
|
||||
TestFindTask PASS
|
||||
TestNextTask_ReadyWithDoneDeps PASS
|
||||
TestNextTask_BlockedByNonDone PASS
|
||||
TestNextTask_NoDependencies PASS
|
||||
TestNextTask_NoReadyTasks PASS
|
||||
TestMoveTask PASS
|
||||
TestMoveTask_NonexistentSource PASS
|
||||
TestMoveTask_CreatesDestFolder PASS
|
||||
TestParseDependencies PASS
|
||||
```
|
||||
|
||||
### Provere
|
||||
|
||||
- `go vet ./...` — čist
|
||||
- `go build ./...` — prolazi
|
||||
- `make all` — prolazi
|
||||
95
TASKS/review/T02.md
Normal file
95
TASKS/review/T02.md
Normal file
@ -0,0 +1,95 @@
|
||||
# T02: Task loader — parsiranje markdown taskova
|
||||
|
||||
**Kreirao:** planer
|
||||
**Datum:** 2026-02-20
|
||||
**Agent:** coder
|
||||
**Model:** Sonnet
|
||||
**Zavisi od:** T01 ✅
|
||||
|
||||
---
|
||||
|
||||
## Opis
|
||||
|
||||
Supervisor mora da čita taskove iz markdown fajlova u TASKS/ folderima.
|
||||
Parsira pojedinačne task fajlove, izvlači ID, status (folder), zavisnosti.
|
||||
|
||||
## Fajlovi za kreiranje
|
||||
|
||||
```
|
||||
code/internal/supervisor/
|
||||
├── task.go ← Task struct, LoadTask(), ScanTasks(), NextTask()
|
||||
└── task_test.go ← testovi sa primer markdown-om
|
||||
```
|
||||
|
||||
## Task struct
|
||||
|
||||
```go
|
||||
type Task struct {
|
||||
ID string // T01, T02...
|
||||
Title string // naslov iz # zaglavlja
|
||||
Status string // backlog, ready, active, review, done (iz foldera)
|
||||
Agent string // coder, checker, frontend...
|
||||
Model string // Haiku, Sonnet, Opus
|
||||
DependsOn []string // lista task ID-eva
|
||||
Description string // ceo opis
|
||||
FilePath string // putanja do fajla
|
||||
}
|
||||
```
|
||||
|
||||
## Funkcije
|
||||
|
||||
- `LoadTask(filePath string) (Task, error)` — parsira jedan task fajl
|
||||
- `ScanTasks(tasksDir string) ([]Task, error)` — skenira sve foldere (backlog/, ready/, active/, review/, done/), učita sve taskove, status iz imena foldera
|
||||
- `FindTask(tasks []Task, id string) *Task` — nađi po ID-u
|
||||
- `NextTask(tasks []Task) *Task` — prvi task u ready/ čije su zavisnosti u done/
|
||||
- `MoveTask(tasksDir, taskID, fromFolder, toFolder string) error` — premesti fajl između foldera
|
||||
|
||||
## Parsiranje
|
||||
|
||||
Task fajl format (primer T01.md):
|
||||
```markdown
|
||||
# T01: Naslov
|
||||
|
||||
**Agent:** coder
|
||||
**Model:** Sonnet
|
||||
**Zavisi od:** —
|
||||
|
||||
---
|
||||
|
||||
## Opis
|
||||
Tekst opisa...
|
||||
```
|
||||
|
||||
Parser izvlači:
|
||||
- ID i Title iz `# T{XX}: Naslov`
|
||||
- Agent iz `**Agent:** vrednost`
|
||||
- Model iz `**Model:** vrednost`
|
||||
- DependsOn iz `**Zavisi od:** T01, T03` (ili `—` za bez zavisnosti)
|
||||
- Description iz `## Opis` sekcije
|
||||
- Status iz imena foldera u kojem se fajl nalazi
|
||||
|
||||
## Testovi
|
||||
|
||||
- LoadTask sa primer fajlom → parsira tačno sva polja
|
||||
- ScanTasks sa temp folderima → nalazi taskove u svim folderima
|
||||
- NextTask: task u ready/ sa zavisnostima u done/ → vraća ga
|
||||
- NextTask: task u ready/ sa zavisnošću u backlog/ → ne vraća
|
||||
- MoveTask: premesti fajl, proveri da je u novom folderu
|
||||
- Edge case: prazan folder, fajl bez zavisnosti, nepostojeći ID
|
||||
|
||||
## Očekivani izlaz
|
||||
|
||||
`go test ./internal/supervisor/ -v` — svi testovi zeleni.
|
||||
`go vet ./...` čist. `go build ./...` prolazi.
|
||||
|
||||
---
|
||||
|
||||
## Pitanja
|
||||
|
||||
*(agent piše pitanja ovde, planer odgovara)*
|
||||
|
||||
---
|
||||
|
||||
## Odgovori
|
||||
|
||||
*(planer piše odgovore ovde)*
|
||||
230
code/internal/supervisor/task.go
Normal file
230
code/internal/supervisor/task.go
Normal file
@ -0,0 +1,230 @@
|
||||
// Package supervisor implements the KAOS supervisor process
|
||||
// that orchestrates agent execution and task management.
|
||||
package supervisor
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Known task folder names and their order.
|
||||
var taskFolders = []string{"backlog", "ready", "active", "review", "done"}
|
||||
|
||||
// Task represents a parsed KAOS task from a markdown file.
|
||||
type Task struct {
|
||||
ID string // T01, T02...
|
||||
Title string // title from # heading
|
||||
Status string // backlog, ready, active, review, done (from folder name)
|
||||
Agent string // coder, checker, frontend...
|
||||
Model string // Haiku, Sonnet, Opus
|
||||
DependsOn []string // list of task IDs
|
||||
Description string // full description text
|
||||
FilePath string // path to the file
|
||||
}
|
||||
|
||||
// headerRegex matches "# T01: Some Title" format.
|
||||
var headerRegex = regexp.MustCompile(`^#\s+(T\d+):\s+(.+)$`)
|
||||
|
||||
// metaRegex matches "**Key:** Value" format.
|
||||
var metaRegex = regexp.MustCompile(`^\*\*(.+?):\*\*\s+(.+)$`)
|
||||
|
||||
// LoadTask parses a single task markdown file and returns a Task.
|
||||
// The Status field is derived from the parent folder name.
|
||||
func LoadTask(filePath string) (Task, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return Task{}, fmt.Errorf("open task file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
task := Task{
|
||||
FilePath: filePath,
|
||||
Status: statusFromPath(filePath),
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
inDescription := false
|
||||
var descLines []string
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
// Check for header line
|
||||
if matches := headerRegex.FindStringSubmatch(line); matches != nil {
|
||||
task.ID = matches[1]
|
||||
task.Title = matches[2]
|
||||
continue
|
||||
}
|
||||
|
||||
// If we're collecting description, check for end marker
|
||||
if inDescription {
|
||||
if strings.HasPrefix(line, "## ") || strings.HasPrefix(line, "---") {
|
||||
inDescription = false
|
||||
continue
|
||||
}
|
||||
descLines = append(descLines, line)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for description section start
|
||||
if strings.HasPrefix(line, "## Opis") {
|
||||
inDescription = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for metadata fields
|
||||
if matches := metaRegex.FindStringSubmatch(line); matches != nil {
|
||||
key := matches[1]
|
||||
value := strings.TrimSpace(matches[2])
|
||||
switch key {
|
||||
case "Agent":
|
||||
task.Agent = value
|
||||
case "Model":
|
||||
task.Model = value
|
||||
case "Zavisi od":
|
||||
task.DependsOn = parseDependencies(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return Task{}, fmt.Errorf("read task file: %w", err)
|
||||
}
|
||||
|
||||
if task.ID == "" {
|
||||
return Task{}, fmt.Errorf("no task ID found in %s", filePath)
|
||||
}
|
||||
|
||||
task.Description = strings.TrimSpace(strings.Join(descLines, "\n"))
|
||||
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// ScanTasks reads all task files from all status folders under tasksDir.
|
||||
func ScanTasks(tasksDir string) ([]Task, error) {
|
||||
var tasks []Task
|
||||
|
||||
for _, folder := range taskFolders {
|
||||
dir := filepath.Join(tasksDir, folder)
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("read folder %s: %w", folder, err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
task, err := LoadTask(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load task %s: %w", path, err)
|
||||
}
|
||||
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
}
|
||||
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
// FindTask returns a pointer to the task with the given ID, or nil if not found.
|
||||
func FindTask(tasks []Task, id string) *Task {
|
||||
for i := range tasks {
|
||||
if tasks[i].ID == id {
|
||||
return &tasks[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NextTask returns the first task in ready/ whose dependencies are all in done/.
|
||||
// Returns nil if no task is ready.
|
||||
func NextTask(tasks []Task) *Task {
|
||||
// Build a set of done task IDs
|
||||
doneSet := make(map[string]bool)
|
||||
for _, t := range tasks {
|
||||
if t.Status == "done" {
|
||||
doneSet[t.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
for i := range tasks {
|
||||
if tasks[i].Status != "ready" {
|
||||
continue
|
||||
}
|
||||
|
||||
allDone := true
|
||||
for _, dep := range tasks[i].DependsOn {
|
||||
if !doneSet[dep] {
|
||||
allDone = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if allDone {
|
||||
return &tasks[i]
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MoveTask moves a task file from one status folder to another.
|
||||
func MoveTask(tasksDir, taskID, fromFolder, toFolder string) error {
|
||||
filename := taskID + ".md"
|
||||
src := filepath.Join(tasksDir, fromFolder, filename)
|
||||
dst := filepath.Join(tasksDir, toFolder, filename)
|
||||
|
||||
// Verify source exists
|
||||
if _, err := os.Stat(src); err != nil {
|
||||
return fmt.Errorf("source task not found: %w", err)
|
||||
}
|
||||
|
||||
// Ensure destination folder exists
|
||||
if err := os.MkdirAll(filepath.Join(tasksDir, toFolder), 0755); err != nil {
|
||||
return fmt.Errorf("create destination folder: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
return fmt.Errorf("move task: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// statusFromPath extracts the status folder name from a file path.
|
||||
// E.g., "/path/to/TASKS/ready/T01.md" → "ready"
|
||||
func statusFromPath(path string) string {
|
||||
dir := filepath.Base(filepath.Dir(path))
|
||||
for _, f := range taskFolders {
|
||||
if dir == f {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// parseDependencies parses "T01, T03" or "T01 ✅, T03" into ["T01", "T03"].
|
||||
// Returns nil for "—" or empty.
|
||||
func parseDependencies(value string) []string {
|
||||
if value == "—" || value == "-" || value == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
depRegex := regexp.MustCompile(`T\d+`)
|
||||
matches := depRegex.FindAllString(value, -1)
|
||||
if len(matches) == 0 {
|
||||
return nil
|
||||
}
|
||||
return matches
|
||||
}
|
||||
381
code/internal/supervisor/task_test.go
Normal file
381
code/internal/supervisor/task_test.go
Normal file
@ -0,0 +1,381 @@
|
||||
package supervisor
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const sampleTask = `# T01: Inicijalizacija Go projekta
|
||||
|
||||
**Kreirao:** planer
|
||||
**Datum:** 2026-02-20
|
||||
**Agent:** coder
|
||||
**Model:** Sonnet
|
||||
**Zavisi od:** —
|
||||
|
||||
---
|
||||
|
||||
## Opis
|
||||
|
||||
Kreirati Go projekat u code/ folderu sa osnovnom strukturom
|
||||
za KAOS supervisor.
|
||||
|
||||
---
|
||||
|
||||
## Pitanja
|
||||
`
|
||||
|
||||
const sampleTaskWithDeps = `# T02: Task loader
|
||||
|
||||
**Agent:** coder
|
||||
**Model:** Sonnet
|
||||
**Zavisi od:** T01, T03
|
||||
|
||||
---
|
||||
|
||||
## Opis
|
||||
|
||||
Implementirati task loader koji parsira markdown.
|
||||
|
||||
---
|
||||
`
|
||||
|
||||
const sampleTaskWithDoneDeps = `# T05: Deploy
|
||||
|
||||
**Agent:** deployer
|
||||
**Model:** Haiku
|
||||
**Zavisi od:** T01 ✅, T02 ✅
|
||||
|
||||
---
|
||||
|
||||
## Opis
|
||||
|
||||
Deploy aplikacije.
|
||||
|
||||
---
|
||||
`
|
||||
|
||||
func writeTempTask(t *testing.T, dir, folder, filename, content string) string {
|
||||
t.Helper()
|
||||
folderPath := filepath.Join(dir, folder)
|
||||
if err := os.MkdirAll(folderPath, 0755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
path := filepath.Join(folderPath, filename)
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestLoadTask_ParsesAllFields(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := writeTempTask(t, dir, "ready", "T01.md", sampleTask)
|
||||
|
||||
task, err := LoadTask(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if task.ID != "T01" {
|
||||
t.Errorf("expected ID T01, got %s", task.ID)
|
||||
}
|
||||
if task.Title != "Inicijalizacija Go projekta" {
|
||||
t.Errorf("expected title 'Inicijalizacija Go projekta', got %q", task.Title)
|
||||
}
|
||||
if task.Agent != "coder" {
|
||||
t.Errorf("expected agent coder, got %s", task.Agent)
|
||||
}
|
||||
if task.Model != "Sonnet" {
|
||||
t.Errorf("expected model Sonnet, got %s", task.Model)
|
||||
}
|
||||
if len(task.DependsOn) != 0 {
|
||||
t.Errorf("expected no dependencies, got %v", task.DependsOn)
|
||||
}
|
||||
if task.Status != "ready" {
|
||||
t.Errorf("expected status ready, got %s", task.Status)
|
||||
}
|
||||
if task.Description == "" {
|
||||
t.Error("expected non-empty description")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTask_WithDependencies(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := writeTempTask(t, dir, "backlog", "T02.md", sampleTaskWithDeps)
|
||||
|
||||
task, err := LoadTask(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(task.DependsOn) != 2 {
|
||||
t.Fatalf("expected 2 dependencies, got %d: %v", len(task.DependsOn), task.DependsOn)
|
||||
}
|
||||
if task.DependsOn[0] != "T01" || task.DependsOn[1] != "T03" {
|
||||
t.Errorf("expected [T01, T03], got %v", task.DependsOn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTask_WithCheckmarkDependencies(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := writeTempTask(t, dir, "ready", "T05.md", sampleTaskWithDoneDeps)
|
||||
|
||||
task, err := LoadTask(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(task.DependsOn) != 2 {
|
||||
t.Fatalf("expected 2 dependencies, got %d: %v", len(task.DependsOn), task.DependsOn)
|
||||
}
|
||||
if task.DependsOn[0] != "T01" || task.DependsOn[1] != "T02" {
|
||||
t.Errorf("expected [T01, T02], got %v", task.DependsOn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTask_NoID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
content := "No header here\nJust some text\n"
|
||||
path := writeTempTask(t, dir, "backlog", "bad.md", content)
|
||||
|
||||
_, err := LoadTask(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for file without task ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTask_NonexistentFile(t *testing.T) {
|
||||
_, err := LoadTask("/nonexistent/path/T99.md")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanTasks_FindsAllFolders(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
writeTempTask(t, dir, "backlog", "T01.md", sampleTask)
|
||||
writeTempTask(t, dir, "ready", "T02.md", sampleTaskWithDeps)
|
||||
writeTempTask(t, dir, "done", "T03.md", `# T03: Done task
|
||||
**Agent:** coder
|
||||
**Model:** Haiku
|
||||
**Zavisi od:** —
|
||||
|
||||
---
|
||||
|
||||
## Opis
|
||||
|
||||
Already done.
|
||||
|
||||
---
|
||||
`)
|
||||
|
||||
// Create empty folders that should be handled gracefully
|
||||
os.MkdirAll(filepath.Join(dir, "active"), 0755)
|
||||
os.MkdirAll(filepath.Join(dir, "review"), 0755)
|
||||
|
||||
tasks, err := ScanTasks(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(tasks) != 3 {
|
||||
t.Fatalf("expected 3 tasks, got %d", len(tasks))
|
||||
}
|
||||
|
||||
// Check statuses
|
||||
statuses := map[string]string{}
|
||||
for _, task := range tasks {
|
||||
statuses[task.ID] = task.Status
|
||||
}
|
||||
|
||||
if statuses["T01"] != "backlog" {
|
||||
t.Errorf("T01 expected backlog, got %s", statuses["T01"])
|
||||
}
|
||||
if statuses["T02"] != "ready" {
|
||||
t.Errorf("T02 expected ready, got %s", statuses["T02"])
|
||||
}
|
||||
if statuses["T03"] != "done" {
|
||||
t.Errorf("T03 expected done, got %s", statuses["T03"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanTasks_EmptyFolder(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Only create empty folders
|
||||
for _, f := range taskFolders {
|
||||
os.MkdirAll(filepath.Join(dir, f), 0755)
|
||||
}
|
||||
|
||||
tasks, err := ScanTasks(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(tasks) != 0 {
|
||||
t.Errorf("expected 0 tasks, got %d", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanTasks_MissingFolders(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Don't create any subfolders
|
||||
|
||||
tasks, err := ScanTasks(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(tasks) != 0 {
|
||||
t.Errorf("expected 0 tasks, got %d", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindTask(t *testing.T) {
|
||||
tasks := []Task{
|
||||
{ID: "T01", Title: "First"},
|
||||
{ID: "T02", Title: "Second"},
|
||||
{ID: "T03", Title: "Third"},
|
||||
}
|
||||
|
||||
found := FindTask(tasks, "T02")
|
||||
if found == nil {
|
||||
t.Fatal("expected to find T02")
|
||||
}
|
||||
if found.Title != "Second" {
|
||||
t.Errorf("expected title Second, got %s", found.Title)
|
||||
}
|
||||
|
||||
notFound := FindTask(tasks, "T99")
|
||||
if notFound != nil {
|
||||
t.Error("expected nil for nonexistent task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTask_ReadyWithDoneDeps(t *testing.T) {
|
||||
tasks := []Task{
|
||||
{ID: "T01", Status: "done"},
|
||||
{ID: "T02", Status: "ready", DependsOn: []string{"T01"}},
|
||||
{ID: "T03", Status: "ready", DependsOn: []string{"T01", "T02"}},
|
||||
}
|
||||
|
||||
next := NextTask(tasks)
|
||||
if next == nil {
|
||||
t.Fatal("expected a next task")
|
||||
}
|
||||
if next.ID != "T02" {
|
||||
t.Errorf("expected T02, got %s", next.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTask_BlockedByNonDone(t *testing.T) {
|
||||
tasks := []Task{
|
||||
{ID: "T01", Status: "active"},
|
||||
{ID: "T02", Status: "ready", DependsOn: []string{"T01"}},
|
||||
}
|
||||
|
||||
next := NextTask(tasks)
|
||||
if next != nil {
|
||||
t.Errorf("expected nil (T02 blocked), got %s", next.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTask_NoDependencies(t *testing.T) {
|
||||
tasks := []Task{
|
||||
{ID: "T01", Status: "ready", DependsOn: nil},
|
||||
}
|
||||
|
||||
next := NextTask(tasks)
|
||||
if next == nil {
|
||||
t.Fatal("expected T01 (no deps)")
|
||||
}
|
||||
if next.ID != "T01" {
|
||||
t.Errorf("expected T01, got %s", next.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTask_NoReadyTasks(t *testing.T) {
|
||||
tasks := []Task{
|
||||
{ID: "T01", Status: "done"},
|
||||
{ID: "T02", Status: "active"},
|
||||
}
|
||||
|
||||
next := NextTask(tasks)
|
||||
if next != nil {
|
||||
t.Errorf("expected nil, got %s", next.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveTask(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTempTask(t, dir, "ready", "T01.md", sampleTask)
|
||||
|
||||
err := MoveTask(dir, "T01", "ready", "active")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify file moved
|
||||
if _, err := os.Stat(filepath.Join(dir, "ready", "T01.md")); !os.IsNotExist(err) {
|
||||
t.Error("expected file removed from ready/")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "active", "T01.md")); err != nil {
|
||||
t.Error("expected file in active/")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveTask_NonexistentSource(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(dir, "ready"), 0755)
|
||||
|
||||
err := MoveTask(dir, "T99", "ready", "active")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveTask_CreatesDestFolder(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTempTask(t, dir, "ready", "T01.md", sampleTask)
|
||||
|
||||
// Don't create active/ — MoveTask should create it
|
||||
err := MoveTask(dir, "T01", "ready", "active")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(dir, "active", "T01.md")); err != nil {
|
||||
t.Error("expected file in active/")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDependencies(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected []string
|
||||
}{
|
||||
{"—", nil},
|
||||
{"-", nil},
|
||||
{"", nil},
|
||||
{"T01", []string{"T01"}},
|
||||
{"T01, T03", []string{"T01", "T03"}},
|
||||
{"T01 ✅", []string{"T01"}},
|
||||
{"T01 ✅, T02 ✅", []string{"T01", "T02"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := parseDependencies(tt.input)
|
||||
if len(result) != len(tt.expected) {
|
||||
t.Errorf("parseDependencies(%q): expected %v, got %v", tt.input, tt.expected, result)
|
||||
continue
|
||||
}
|
||||
for i := range result {
|
||||
if result[i] != tt.expected[i] {
|
||||
t.Errorf("parseDependencies(%q)[%d]: expected %s, got %s", tt.input, i, tt.expected[i], result[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user