package tasks import ( "context" "errors" "strings" "testing" ) func TestParse(t *testing.T) { cases := []struct { name string in string want []Task wantErr bool }{ { name: "valid array", in: `[{"id":"a","title":"Write spec","parentId":"p","day":"2026-05-31","done":false}]`, want: []Task{{ID: "a", Title: "Write spec", Day: "2026-05-31"}}, }, {name: "empty array", in: `[]`, want: []Task{}}, {name: "json null", in: `null`, want: []Task{}}, { name: "drops done and empty-title", in: `[{"id":"a","title":"keep","day":"","done":false},{"id":"b","title":"done one","done":true},{"id":"c","title":" ","done":false}]`, want: []Task{{ID: "a", Title: "keep", Day: ""}}, }, {name: "malformed", in: `not json`, wantErr: true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got, err := parse([]byte(tc.in)) if tc.wantErr { if err == nil { t.Fatal("want error, got nil") } return } if err != nil { t.Fatalf("parse: %v", err) } if len(got) != len(tc.want) { t.Fatalf("len = %d, want %d (%+v)", len(got), len(tc.want), got) } for i := range got { if got[i] != tc.want[i] { t.Errorf("[%d] = %+v, want %+v", i, got[i], tc.want[i]) } } }) } } func TestNewMarvinCommandSplitting(t *testing.T) { cases := []struct { in string wantCmd string wantArgs []string }{ {"", "am", []string{"--json"}}, {"am", "am", []string{"--json"}}, {"uv run am", "uv", []string{"run", "am", "--json"}}, } for _, tc := range cases { m := NewMarvin(tc.in) if m.cmd != tc.wantCmd { t.Errorf("NewMarvin(%q).cmd = %q, want %q", tc.in, m.cmd, tc.wantCmd) } if len(m.args) != len(tc.wantArgs) { t.Fatalf("NewMarvin(%q).args = %v, want %v", tc.in, m.args, tc.wantArgs) } for i := range m.args { if m.args[i] != tc.wantArgs[i] { t.Errorf("NewMarvin(%q).args[%d] = %q, want %q", tc.in, i, m.args[i], tc.wantArgs[i]) } } } } func TestTodayUsesRunnerOutput(t *testing.T) { m := NewMarvin("am") var gotName string var gotArgs []string m.run = func(ctx context.Context, name string, args ...string) ([]byte, error) { gotName, gotArgs = name, args return []byte(`[{"id":"x","title":"Do thing","day":"2026-05-31","done":false}]`), nil } got, err := m.Today(context.Background()) if err != nil { t.Fatalf("Today: %v", err) } if gotName != "am" || len(gotArgs) != 1 || gotArgs[0] != "--json" { t.Fatalf("runner called with %q %v", gotName, gotArgs) } if len(got) != 1 || got[0].Title != "Do thing" { t.Fatalf("Today result = %+v", got) } } func TestTodayPropagatesError(t *testing.T) { m := NewMarvin("am") m.run = func(ctx context.Context, name string, args ...string) ([]byte, error) { return nil, errors.New("am not found") } if _, err := m.Today(context.Background()); err == nil { t.Fatal("want error from failing runner") } } func TestCreateInvokesMarvinAddPreservingCRDT(t *testing.T) { m := NewMarvin("uv run am") var gotName string var gotArgs []string m.run = func(ctx context.Context, name string, args ...string) ([]byte, error) { gotName, gotArgs = name, args return []byte(`{"id":"task-1"}`), nil } if err := m.Create(context.Background(), NewTask("buy groceries")); err != nil { t.Fatalf("Create: %v", err) } // Same prefix the read path shells out through, so add goes through the // same ampy entrypoint and preserves its CRDT bookkeeping. if gotName != "uv" { t.Fatalf("runner name = %q, want %q", gotName, "uv") } joined := strings.Join(gotArgs, " ") if !strings.Contains(joined, "add") || !strings.Contains(joined, "buy groceries") { t.Fatalf("args = %v, want marvin add with title", gotArgs) } } func TestCreatePropagatesError(t *testing.T) { m := NewMarvin("am") m.run = func(ctx context.Context, name string, args ...string) ([]byte, error) { return nil, errors.New("am add failed") } if err := m.Create(context.Background(), NewTask("buy groceries")); err == nil { t.Fatal("want error from failing runner") } }