package server import ( "net/http" "net/http/httptest" "os" "path/filepath" "regexp" "strings" "testing" ) func TestMoveTask_AddsTimestamp(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodPost, "/task/T08/move?to=ready", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } // Read the moved file and check for timestamp content, err := os.ReadFile(filepath.Join(srv.Config.TasksDir, "ready", "T08.md")) if err != nil { t.Fatalf("failed to read moved file: %v", err) } text := string(content) if !containsStr(text, "## Vremena") { t.Error("expected '## Vremena' section in task file") } if !containsStr(text, "Odobren (→ready)") { t.Error("expected 'Odobren (→ready)' timestamp") } } func TestMoveTask_AppendsMultipleTimestamps(t *testing.T) { srv := setupTestServer(t) // Move backlog → ready req := httptest.NewRequest(http.MethodPost, "/task/T08/move?to=ready", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) // Move ready → backlog (return) req2 := httptest.NewRequest(http.MethodPost, "/task/T08/move?to=backlog", nil) w2 := httptest.NewRecorder() // Need to re-fetch since task is now in ready/ srv.Router.ServeHTTP(w2, req2) // Move backlog → ready again req3 := httptest.NewRequest(http.MethodPost, "/task/T08/move?to=ready", nil) w3 := httptest.NewRecorder() srv.Router.ServeHTTP(w3, req3) content, _ := os.ReadFile(filepath.Join(srv.Config.TasksDir, "ready", "T08.md")) text := string(content) // Should have two "Odobren (→ready)" entries count := strings.Count(text, "Odobren (→ready)") if count != 2 { t.Errorf("expected 2 'Odobren (→ready)' timestamps, got %d", count) } } func TestAppendTimestamp_CreatesTable(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "test.md") os.WriteFile(path, []byte("# T01: Test\n\nSome content.\n"), 0644) err := appendTimestamp(path, "Odobren (→ready)") if err != nil { t.Fatalf("unexpected error: %v", err) } content, _ := os.ReadFile(path) text := string(content) if !containsStr(text, "## Vremena") { t.Error("expected Vremena section") } if !containsStr(text, "| Događaj | Vreme |") { t.Error("expected table header") } if !containsStr(text, "Odobren (→ready)") { t.Error("expected timestamp row") } } func TestAppendTimestamp_AppendsToExisting(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "test.md") os.WriteFile(path, []byte("# T01: Test\n\n## Vremena\n\n| Događaj | Vreme |\n|---------|-------|\n| Kreiran | 2026-02-20 14:00 |\n"), 0644) err := appendTimestamp(path, "Odobren (→ready)") if err != nil { t.Fatalf("unexpected error: %v", err) } content, _ := os.ReadFile(path) text := string(content) if !containsStr(text, "Kreiran") { t.Error("expected existing row preserved") } if !containsStr(text, "Odobren (→ready)") { t.Error("expected new timestamp row") } } func TestRunTask_AddsTimestamp(t *testing.T) { srv := setupTestServer(t) // Move T08 to ready os.Rename( filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"), filepath.Join(srv.Config.TasksDir, "ready", "T08.md"), ) req := httptest.NewRequest(http.MethodPost, "/task/T08/run", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } // Task should now be in active/ content, _ := os.ReadFile(filepath.Join(srv.Config.TasksDir, "active", "T08.md")) text := string(content) if !containsStr(text, "Pokrenut") { t.Error("expected 'Pokrenut' timestamp after run") } // Verify it's no longer in ready/ if _, err := os.Stat(filepath.Join(srv.Config.TasksDir, "ready", "T08.md")); err == nil { t.Error("task should no longer be in ready/") } } func TestAppendTimestamp_Format(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "test.md") os.WriteFile(path, []byte("# T01: Test\n"), 0644) appendTimestamp(path, "Pokrenut (→active)") content, _ := os.ReadFile(path) text := string(content) // Verify timestamp format YYYY-MM-DD HH:MM re := regexp.MustCompile(`\| Pokrenut \(→active\) \| \d{4}-\d{2}-\d{2} \d{2}:\d{2} \|`) if !re.MatchString(text) { t.Errorf("expected timestamp format YYYY-MM-DD HH:MM, got:\n%s", text) } } func TestAppendTimestamp_FileNotFound(t *testing.T) { err := appendTimestamp("/nonexistent/path/task.md", "test") if err == nil { t.Error("expected error for nonexistent file") } } func TestMoveEventLabel_AllFolders(t *testing.T) { expected := map[string]string{ "ready": "Odobren (→ready)", "active": "Pokrenut (→active)", "review": "Završen (→review)", "done": "Odobren (→done)", } for folder, label := range expected { got, ok := moveEventLabel[folder] if !ok { t.Errorf("missing label for folder %s", folder) continue } if got != label { t.Errorf("folder %s: expected %q, got %q", folder, label, got) } } } func TestDoneTimestamp_ReviewToDone(t *testing.T) { srv := setupTestServer(t) // Put T08 in review os.Rename( filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"), filepath.Join(srv.Config.TasksDir, "review", "T08.md"), ) req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=done", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } content, _ := os.ReadFile(filepath.Join(srv.Config.TasksDir, "done", "T08.md")) text := string(content) if !containsStr(text, "Odobren (→done)") { t.Error("expected 'Odobren (→done)' timestamp") } } func TestTaskDetail_DoneShowsReportButton(t *testing.T) { srv := setupTestServer(t) // T01 is in done and has a report req := httptest.NewRequest(http.MethodGet, "/task/T01", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } body := w.Body.String() if !containsStr(body, "/report/T01") { t.Error("expected report link for done task") } } func TestTaskDetail_DoneWithoutReportShowsButton(t *testing.T) { srv := setupTestServer(t) // Create a done task without a report os.WriteFile( filepath.Join(srv.Config.TasksDir, "done", "T02.md"), []byte("# T02: Bez reporta\n\n**Agent:** coder\n**Model:** Sonnet\n**Zavisi od:** —\n\n---\n\n## Opis\n\nTest task bez reporta.\n"), 0644, ) req := httptest.NewRequest(http.MethodGet, "/task/T02", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } body := w.Body.String() if !containsStr(body, "/report/T02") { t.Error("expected report button for done task even without report file") } } func TestTimestampVisibleInTaskDetail(t *testing.T) { srv := setupTestServer(t) // Add timestamps to T01 (done task) taskPath := filepath.Join(srv.Config.TasksDir, "done", "T01.md") appendTimestamp(taskPath, "Kreiran") appendTimestamp(taskPath, "Pokrenut (→active)") req := httptest.NewRequest(http.MethodGet, "/task/T01", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } body := w.Body.String() if !containsStr(body, "Vremena") { t.Error("expected Vremena section visible in task detail") } } func TestStripAnsi(t *testing.T) { tests := []struct { input string expected string }{ {"hello", "hello"}, {"\x1b[32mgreen\x1b[0m", "green"}, {"\x1b[1;31mbold red\x1b[0m", "bold red"}, {"\x1b[?25l\x1b[?25h", ""}, {"no \x1b[4munderline\x1b[24m here", "no underline here"}, {"\x1b]0;title\x07text", "text"}, } for _, tt := range tests { got := stripAnsi(tt.input) if got != tt.expected { t.Errorf("stripAnsi(%q) = %q, want %q", tt.input, got, tt.expected) } } } func TestReadPTY_SplitsLines(t *testing.T) { r, w, _ := os.Pipe() var lines []string done := make(chan bool) go func() { readPTY(r, func(line string) { lines = append(lines, line) }) done <- true }() w.WriteString("line1\nline2\nline3\n") w.Close() <-done if len(lines) != 3 { t.Fatalf("expected 3 lines, got %d: %v", len(lines), lines) } if lines[0] != "line1" || lines[1] != "line2" || lines[2] != "line3" { t.Errorf("unexpected lines: %v", lines) } } func TestReadPTY_StripsAnsi(t *testing.T) { r, w, _ := os.Pipe() var lines []string done := make(chan bool) go func() { readPTY(r, func(line string) { lines = append(lines, line) }) done <- true }() w.WriteString("\x1b[32mcolored\x1b[0m\n") w.Close() <-done if len(lines) != 1 { t.Fatalf("expected 1 line, got %d", len(lines)) } if lines[0] != "colored" { t.Errorf("expected 'colored', got %q", lines[0]) } } func TestReadPTY_HandlesPartialChunks(t *testing.T) { r, w, _ := os.Pipe() var lines []string done := make(chan bool) go func() { readPTY(r, func(line string) { lines = append(lines, line) }) done <- true }() // Write partial, then complete w.WriteString("partial") w.Close() <-done if len(lines) != 1 { t.Fatalf("expected 1 line for partial, got %d: %v", len(lines), lines) } if lines[0] != "partial" { t.Errorf("expected 'partial', got %q", lines[0]) } } func TestReadPTY_HandlesCarriageReturn(t *testing.T) { r, w, _ := os.Pipe() var lines []string done := make(chan bool) go func() { readPTY(r, func(line string) { lines = append(lines, line) }) done <- true }() w.WriteString("line1\r\nline2\r\n") w.Close() <-done if len(lines) != 2 { t.Fatalf("expected 2 lines, got %d: %v", len(lines), lines) } if lines[0] != "line1" || lines[1] != "line2" { t.Errorf("unexpected lines: %v", lines) } } func TestRingBuffer_WriteAndRead(t *testing.T) { rb := NewRingBuffer(16) rb.Write([]byte("hello")) got := rb.Bytes() if string(got) != "hello" { t.Errorf("expected 'hello', got '%s'", got) } } func TestRingBuffer_Overflow(t *testing.T) { rb := NewRingBuffer(8) rb.Write([]byte("abcdefgh")) // exactly fills rb.Write([]byte("ij")) // wraps around got := rb.Bytes() // Should contain the last 8 bytes: "cdefghij" if string(got) != "cdefghij" { t.Errorf("expected 'cdefghij', got '%s'", got) } } func TestRingBuffer_Reset(t *testing.T) { rb := NewRingBuffer(16) rb.Write([]byte("test")) rb.Reset() got := rb.Bytes() if len(got) != 0 { t.Errorf("expected empty after reset, got %d bytes", len(got)) } }