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