Files
projax/web/task_test.go
mAi ef507b4e1b feat(web): Phase 7c STEP 2 — unify mBrian + CalDAV tasks into ONE list
Supersedes the Phase 7b two-section split (m: 'tasks section should collect
from mBrian AS WELL AS CalDAV and display together'). The detail page now
renders a single merged task list per project:

- buildUnifiedTasks merges mBrian-native tasks (TasksForItem) + CalDAV VTODOs
  (detailTodos → taskFromTodo) into one open/done split via the uniform
  store.Task shape. Each row carries a subtle source label (calendar name for
  CalDAV, 'projax' for mBrian). Sorted: dated-before-undated, earlier due
  first, then created, then title.
- Row actions dispatch by Source: CalDAV rows POST /caldav/todo/{action}
  (calendar_url+uid, ETag writeback); mBrian rows POST /task/{action}
  (node_id). One template, branch on .Source.
- ONE add-form, backend by §3.1 selector (unifiedTasks.AddTarget): CalDAV on a
  bound project (hidden calendar_url for a single list, a <select> when
  several), mBrian-native on an unbound project. New-task default per m's spec.
- Both handlers (handleCalDAVTodoAction + handleTaskAction) now re-render the
  SAME merged fragment via renderUnifiedTasks, so a write from either backend
  refreshes the unified list in place.
- Retired the two-section split: deleted mbrian_tasks_section.tmpl + its
  registration/render-case, rewrote tasks_section.tmpl as the unified list,
  removed renderTasksSection. CalDAV link/create-list affordances preserved.

Unit-tested: sortTaskRows (merge order), AddTarget (backend selection across
bound/unbound × single/multi-calendar). Updated TestDetailLinkExistingCalendar
to the unified UI (no per-calendar block; bound project → add-form targets the
linked calendar, create-new hidden). caldav/gitea/mcp/internal green; the 8
remaining web failures are the pre-existing TestProjectFilter*/TestTimeline*
route-drift (fail on 6436b52).
2026-06-01 18:27:49 +02:00

121 lines
3.7 KiB
Go

package web
import (
"testing"
"time"
"github.com/m/projax/caldav"
"github.com/m/projax/store"
)
func TestTaskFromTodo(t *testing.T) {
due := time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC)
mod := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
td := caldav.Todo{
UID: "vtodo-123",
Summary: "Pour foundation",
Status: "NEEDS-ACTION",
Due: &due,
LastModified: &mod,
}
got := taskFromTodo(td, "https://dav/cal/", "item-uuid")
if got.Source != store.TaskSourceCalDAV {
t.Fatalf("source = %q, want caldav", got.Source)
}
if got.ID != "vtodo-123" || got.UID != "vtodo-123" {
t.Fatalf("id/uid = %q/%q", got.ID, got.UID)
}
if got.Title != "Pour foundation" {
t.Fatalf("title = %q", got.Title)
}
if got.Done {
t.Fatal("NEEDS-ACTION should not be done")
}
if got.CalendarURL != "https://dav/cal/" {
t.Fatalf("calURL = %q", got.CalendarURL)
}
if got.ParentItemID != "item-uuid" {
t.Fatalf("parent = %q", got.ParentItemID)
}
if got.Due == nil || !got.Due.Equal(due) {
t.Fatalf("due = %v, want %v", got.Due, due)
}
if !got.CreatedAt.Equal(mod) {
t.Fatalf("createdAt = %v, want last-modified %v", got.CreatedAt, mod)
}
}
func TestTaskFromTodoDoneStates(t *testing.T) {
for _, st := range []string{"COMPLETED", "CANCELLED"} {
got := taskFromTodo(caldav.Todo{UID: "x", Status: st}, "c", "i")
if !got.Done {
t.Fatalf("status %q should map to done", st)
}
}
for _, st := range []string{"NEEDS-ACTION", "IN-PROCESS", ""} {
got := taskFromTodo(caldav.Todo{UID: "x", Status: st}, "c", "i")
if got.Done {
t.Fatalf("status %q should NOT map to done", st)
}
}
}
func TestRenderHint(t *testing.T) {
if renderHint(true) != "checklist" {
t.Fatal("true should map to checklist")
}
if renderHint(false) != "" {
t.Fatal("false should map to empty")
}
}
func TestSortTaskRows(t *testing.T) {
d := func(s string) *time.Time {
tm, _ := time.Parse("2006-01-02", s)
return &tm
}
mk := func(title string, due *time.Time, created string) taskRow {
c, _ := time.Parse("2006-01-02", created)
return taskRow{Task: &store.Task{Title: title, Due: due, CreatedAt: c}}
}
rows := []taskRow{
mk("undated-late", nil, "2026-06-03"),
mk("due-later", d("2026-06-20"), "2026-06-01"),
mk("undated-early", nil, "2026-06-02"),
mk("due-soon", d("2026-06-10"), "2026-06-05"),
}
sortTaskRows(rows)
got := []string{rows[0].Title, rows[1].Title, rows[2].Title, rows[3].Title}
want := []string{"due-soon", "due-later", "undated-early", "undated-late"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("sort order = %v, want %v", got, want)
}
}
}
func TestAddTarget(t *testing.T) {
cal := func(url string) caldav.Calendar { return caldav.Calendar{URL: url, DisplayName: url} }
// CalDAV-bound, single calendar → caldav + that URL.
u := unifiedTasks{CalDAVBound: true, LinkedCalendars: []caldav.Calendar{cal("https://c1/")}}
if at := u.AddTarget(); at.Mode != "caldav" || at.CalendarURL != "https://c1/" {
t.Fatalf("single-cal bound = %+v, want caldav+https://c1/", at)
}
// CalDAV-bound, multiple calendars → caldav + empty URL (form shows select).
u = unifiedTasks{CalDAVBound: true, LinkedCalendars: []caldav.Calendar{cal("https://c1/"), cal("https://c2/")}}
if at := u.AddTarget(); at.Mode != "caldav" || at.CalendarURL != "" {
t.Fatalf("multi-cal bound = %+v, want caldav+empty", at)
}
// Unbound + mBrian backend → mbrian.
u = unifiedTasks{CalDAVBound: false, MBrianOn: true}
if at := u.AddTarget(); at.Mode != "mbrian" {
t.Fatalf("unbound+mbrian = %+v, want mbrian", at)
}
// Unbound + no mBrian backend → no add affordance.
u = unifiedTasks{CalDAVBound: false, MBrianOn: false}
if at := u.AddTarget(); at.Mode != "" {
t.Fatalf("unbound+no-backend = %+v, want empty mode", at)
}
}