From 46113d978fcad884b721000c737d820393fb27dd Mon Sep 17 00:00:00 2001 From: Amolith Date: Sun, 21 Dec 2025 18:50:05 -0700 Subject: [PATCH] feat(tasks): add typed Priority API Replace *int with *Priority for type safety. Rename WithPriority(int) to Priority(Priority) on TaskBuilder and TaskUpdateBuilder. Includes: - Priority constants: PriorityLowest (-2) to PriorityHighest (2) - String(), Valid(), ParsePriority(), JSON marshal/unmarshal - Sentinel errors: ErrInvalidPriority, ErrPriorityOutOfRange Assisted-by: Claude Sonnet 4 via Amp --- priority.go | 109 +++++++++++++++ priority_test.go | 339 +++++++++++++++++++++++++++++++++++++++++++++++ tasks.go | 18 +-- tasks_test.go | 4 +- 4 files changed, 459 insertions(+), 11 deletions(-) create mode 100644 priority.go create mode 100644 priority_test.go diff --git a/priority.go b/priority.go new file mode 100644 index 0000000000000000000000000000000000000000..e72eb76b1b5b08cca4cd7155c60e62e30b628bb1 --- /dev/null +++ b/priority.go @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package lunatask + +import ( + "encoding/json" + "errors" + "fmt" + "strings" +) + +// Errors returned by Priority operations. +var ( + // ErrInvalidPriority is returned when parsing an unknown priority string. + ErrInvalidPriority = errors.New("invalid priority") + // ErrPriorityOutOfRange is returned when a priority value is outside [-2, 2]. + ErrPriorityOutOfRange = errors.New("priority out of range") +) + +// Priority represents task priority level from lowest (-2) to highest (2). +// The default priority is [PriorityNormal] (0). +type Priority int + +// Task priority levels. See [Priority] for the full range. +const ( + PriorityLowest Priority = -2 + PriorityLow Priority = -1 + PriorityNormal Priority = 0 + PriorityHigh Priority = 1 + PriorityHighest Priority = 2 +) + +// String returns the lowercase name of the priority level. +func (p *Priority) String() string { + switch *p { + case PriorityLowest: + return "lowest" + case PriorityLow: + return "low" + case PriorityNormal: + return "normal" + case PriorityHigh: + return "high" + case PriorityHighest: + return "highest" + default: + return fmt.Sprintf("Priority(%d)", *p) + } +} + +// Valid reports whether the priority is within the valid range [-2, 2]. +func (p *Priority) Valid() bool { + return *p >= PriorityLowest && *p <= PriorityHighest +} + +// ParsePriority parses a string to a Priority value (case-insensitive). +// Valid values: "lowest", "low", "normal", "high", "highest". +func ParsePriority(str string) (Priority, error) { + switch strings.ToLower(str) { + case "lowest": + return PriorityLowest, nil + case "low": + return PriorityLow, nil + case "normal": + return PriorityNormal, nil + case "high": + return PriorityHigh, nil + case "highest": + return PriorityHighest, nil + default: + return 0, fmt.Errorf("%w: %q", ErrInvalidPriority, str) + } +} + +// MarshalJSON implements [json.Marshaler]. +// Priority marshals as its integer value. +func (p *Priority) MarshalJSON() ([]byte, error) { + data, err := json.Marshal(int(*p)) + if err != nil { + return nil, fmt.Errorf("marshaling priority: %w", err) + } + + return data, nil +} + +// UnmarshalJSON implements [json.Unmarshaler]. +// Priority unmarshals from an integer value in the range [-2, 2]. +func (p *Priority) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + *p = PriorityNormal + + return nil + } + + var val int + if err := json.Unmarshal(data, &val); err != nil { + return fmt.Errorf("priority must be an integer: %w", err) + } + + if val < -2 || val > 2 { + return fmt.Errorf("%w: %d", ErrPriorityOutOfRange, val) + } + + *p = Priority(val) + + return nil +} diff --git a/priority_test.go b/priority_test.go new file mode 100644 index 0000000000000000000000000000000000000000..729f214b52a6047a30d558ac0b4a4a33650ee6fb --- /dev/null +++ b/priority_test.go @@ -0,0 +1,339 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package lunatask_test + +import ( + "encoding/json" + "testing" + + lunatask "git.secluded.site/go-lunatask" +) + +// --- Priority Type Constants --- + +func TestPriority_Constants(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value lunatask.Priority + want int + }{ + {"lowest", lunatask.PriorityLowest, -2}, + {"low", lunatask.PriorityLow, -1}, + {"normal", lunatask.PriorityNormal, 0}, + {"high", lunatask.PriorityHigh, 1}, + {"highest", lunatask.PriorityHighest, 2}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if int(tc.value) != tc.want { + t.Errorf("Priority constant = %d, want %d", tc.value, tc.want) + } + }) + } +} + +// --- Priority String Method --- + +func TestPriority_String(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value lunatask.Priority + want string + }{ + {"lowest", lunatask.PriorityLowest, "lowest"}, + {"low", lunatask.PriorityLow, "low"}, + {"normal", lunatask.PriorityNormal, "normal"}, + {"high", lunatask.PriorityHigh, "high"}, + {"highest", lunatask.PriorityHighest, "highest"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := tc.value.String(); got != tc.want { + t.Errorf("Priority(%d).String() = %q, want %q", tc.value, got, tc.want) + } + }) + } +} + +func TestPriority_String_Unknown(t *testing.T) { + t.Parallel() + + p := lunatask.Priority(99) + if got := p.String(); got != "Priority(99)" { + t.Errorf("Priority(99).String() = %q, want %q", got, "Priority(99)") + } +} + +// --- Priority Valid Method --- + +func TestPriority_Valid(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value lunatask.Priority + want bool + }{ + {"lowest", lunatask.PriorityLowest, true}, + {"low", lunatask.PriorityLow, true}, + {"normal", lunatask.PriorityNormal, true}, + {"high", lunatask.PriorityHigh, true}, + {"highest", lunatask.PriorityHighest, true}, + {"below_range", lunatask.Priority(-3), false}, + {"above_range", lunatask.Priority(3), false}, + {"way_above", lunatask.Priority(99), false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := tc.value.Valid(); got != tc.want { + t.Errorf("Priority(%d).Valid() = %v, want %v", tc.value, got, tc.want) + } + }) + } +} + +// --- ParsePriority Function --- + +func TestParsePriority(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want lunatask.Priority + wantErr bool + }{ + {"lowest_lower", "lowest", lunatask.PriorityLowest, false}, + {"lowest_upper", "LOWEST", lunatask.PriorityLowest, false}, + {"lowest_mixed", "LoWeSt", lunatask.PriorityLowest, false}, + {"low_lower", "low", lunatask.PriorityLow, false}, + {"low_upper", "LOW", lunatask.PriorityLow, false}, + {"normal_lower", "normal", lunatask.PriorityNormal, false}, + {"normal_upper", "NORMAL", lunatask.PriorityNormal, false}, + {"high_lower", "high", lunatask.PriorityHigh, false}, + {"high_upper", "HIGH", lunatask.PriorityHigh, false}, + {"highest_lower", "highest", lunatask.PriorityHighest, false}, + {"highest_upper", "HIGHEST", lunatask.PriorityHighest, false}, + {"invalid", "invalid", 0, true}, + {"empty", "", 0, true}, + {"numeric", "1", 0, true}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + got, err := lunatask.ParsePriority(testCase.input) + if (err != nil) != testCase.wantErr { + t.Errorf("ParsePriority(%q) error = %v, wantErr %v", testCase.input, err, testCase.wantErr) + + return + } + + if !testCase.wantErr && got != testCase.want { + t.Errorf("ParsePriority(%q) = %d, want %d", testCase.input, got, testCase.want) + } + }) + } +} + +// --- Priority JSON Marshaling --- + +func TestPriority_MarshalJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value lunatask.Priority + want string + }{ + {"lowest", lunatask.PriorityLowest, "-2"}, + {"low", lunatask.PriorityLow, "-1"}, + {"normal", lunatask.PriorityNormal, "0"}, + {"high", lunatask.PriorityHigh, "1"}, + {"highest", lunatask.PriorityHighest, "2"}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + got, err := json.Marshal(testCase.value) + if err != nil { + t.Fatalf("Marshal error: %v", err) + } + + if string(got) != testCase.want { + t.Errorf("json.Marshal(Priority) = %s, want %s", got, testCase.want) + } + }) + } +} + +func TestPriority_UnmarshalJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want lunatask.Priority + wantErr bool + }{ + {"lowest", "-2", lunatask.PriorityLowest, false}, + {"low", "-1", lunatask.PriorityLow, false}, + {"normal", "0", lunatask.PriorityNormal, false}, + {"high", "1", lunatask.PriorityHigh, false}, + {"highest", "2", lunatask.PriorityHighest, false}, + {"null", "null", lunatask.PriorityNormal, false}, + {"out_of_range_high", "5", 0, true}, + {"out_of_range_low", "-5", 0, true}, + {"string", `"high"`, 0, true}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + var got lunatask.Priority + + err := json.Unmarshal([]byte(testCase.input), &got) + + if (err != nil) != testCase.wantErr { + t.Errorf("Unmarshal error = %v, wantErr %v", err, testCase.wantErr) + + return + } + + if !testCase.wantErr && got != testCase.want { + t.Errorf("Unmarshal(%s) = %d, want %d", testCase.input, got, testCase.want) + } + }) + } +} + +// --- TaskBuilder Priority Method --- + +func TestTaskBuilder_Priority(t *testing.T) { + t.Parallel() + + server, capture := newPOSTServer(t, "/tasks", singleTaskResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + _, err := client.NewTask("High priority task"). + Priority(lunatask.PriorityHigh). + Create(ctx()) + if err != nil { + t.Fatalf("error = %v", err) + } + + assertBodyFieldFloat(t, capture.Body, "priority", 1) +} + +func TestTaskBuilder_Priority_AllValues(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + priority lunatask.Priority + want float64 + }{ + {"lowest", lunatask.PriorityLowest, -2}, + {"low", lunatask.PriorityLow, -1}, + {"normal", lunatask.PriorityNormal, 0}, + {"high", lunatask.PriorityHigh, 1}, + {"highest", lunatask.PriorityHighest, 2}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + server, capture := newPOSTServer(t, "/tasks", singleTaskResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + _, err := client.NewTask("Task"). + Priority(testCase.priority). + Create(ctx()) + if err != nil { + t.Fatalf("error = %v", err) + } + + assertBodyFieldFloat(t, capture.Body, "priority", testCase.want) + }) + } +} + +// --- TaskUpdateBuilder Priority Method --- + +func TestTaskUpdateBuilder_Priority(t *testing.T) { + t.Parallel() + + server, capture := newPUTServer(t, "/tasks/"+taskID, singleTaskResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + _, err := client.NewTaskUpdate(taskID). + Priority(lunatask.PriorityLowest). + Update(ctx()) + if err != nil { + t.Fatalf("error = %v", err) + } + + assertBodyFieldFloat(t, capture.Body, "priority", -2) +} + +func TestTaskUpdateBuilder_Priority_AllValues(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + priority lunatask.Priority + want float64 + }{ + {"lowest", lunatask.PriorityLowest, -2}, + {"low", lunatask.PriorityLow, -1}, + {"normal", lunatask.PriorityNormal, 0}, + {"high", lunatask.PriorityHigh, 1}, + {"highest", lunatask.PriorityHighest, 2}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + server, capture := newPUTServer(t, "/tasks/"+taskID, singleTaskResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + _, err := client.NewTaskUpdate(taskID). + Priority(testCase.priority). + Update(ctx()) + if err != nil { + t.Fatalf("error = %v", err) + } + + assertBodyFieldFloat(t, capture.Body, "priority", testCase.want) + }) + } +} diff --git a/tasks.go b/tasks.go index a3f3ed382f8cdeb578b786dde1391d1e0a5ece34..fb50e701ef7bda17b86c47dab0a8480b892c198c 100644 --- a/tasks.go +++ b/tasks.go @@ -20,7 +20,7 @@ type Task struct { Status *TaskStatus `json:"status"` PreviousStatus *TaskStatus `json:"previous_status"` Estimate *int `json:"estimate"` - Priority *int `json:"priority"` + Priority *Priority `json:"priority"` Progress *int `json:"progress"` Motivation *Motivation `json:"motivation"` Eisenhower *Eisenhower `json:"eisenhower"` @@ -40,7 +40,7 @@ type createTaskRequest struct { Status *TaskStatus `json:"status,omitempty"` Motivation *Motivation `json:"motivation,omitempty"` Estimate *int `json:"estimate,omitempty"` - Priority *int `json:"priority,omitempty"` + Priority *Priority `json:"priority,omitempty"` Eisenhower *Eisenhower `json:"eisenhower,omitempty"` ScheduledOn *Date `json:"scheduled_on,omitempty"` CompletedAt *time.Time `json:"completed_at,omitempty"` @@ -57,7 +57,7 @@ type updateTaskRequest struct { Status *TaskStatus `json:"status,omitempty"` Motivation *Motivation `json:"motivation,omitempty"` Estimate *int `json:"estimate,omitempty"` - Priority *int `json:"priority,omitempty"` + Priority *Priority `json:"priority,omitempty"` Eisenhower *Eisenhower `json:"eisenhower,omitempty"` ScheduledOn *Date `json:"scheduled_on,omitempty"` CompletedAt *time.Time `json:"completed_at,omitempty"` @@ -168,9 +168,9 @@ func (b *TaskBuilder) WithEstimate(minutes int) *TaskBuilder { return b } -// WithPriority sets importance from -2 (lowest) to 2 (highest). -func (b *TaskBuilder) WithPriority(priority int) *TaskBuilder { - b.req.Priority = &priority +// Priority sets the priority level. Use Priority* constants (e.g., [PriorityHigh]). +func (b *TaskBuilder) Priority(p Priority) *TaskBuilder { + b.req.Priority = &p return b } @@ -328,9 +328,9 @@ func (b *TaskUpdateBuilder) WithEstimate(minutes int) *TaskUpdateBuilder { return b } -// WithPriority sets importance from -2 (lowest) to 2 (highest). -func (b *TaskUpdateBuilder) WithPriority(priority int) *TaskUpdateBuilder { - b.req.Priority = &priority +// Priority sets the priority level. Use Priority* constants (e.g., [PriorityHigh]). +func (b *TaskUpdateBuilder) Priority(p Priority) *TaskUpdateBuilder { + b.req.Priority = &p return b } diff --git a/tasks_test.go b/tasks_test.go index db9c958e98b54af9503c7aa5eabb58ade290c3d1..8af902b305b770ea8123ad76d878cd379b5c6050 100644 --- a/tasks_test.go +++ b/tasks_test.go @@ -206,7 +206,7 @@ func TestCreateTask_AllBuilderFields(t *testing.T) { WithMotivation(lunatask.MotivationWant). WithEisenhower(1). WithEstimate(60). - WithPriority(2). + Priority(lunatask.PriorityHighest). ScheduledOn(scheduledDate). CompletedAt(completedTime). FromSource(sourceGitHub, sourceID123). @@ -313,7 +313,7 @@ func TestUpdateTask_AllBuilderFields(t *testing.T) { WithMotivation(lunatask.MotivationShould). WithEisenhower(2). WithEstimate(90). - WithPriority(-1). + Priority(lunatask.PriorityLow). ScheduledOn(scheduledDate). CompletedAt(completedTime). Update(ctx())