Skip to content

Commit bb7e653

Browse files
authored
Task Status (#16)
1 parent cee4b1d commit bb7e653

File tree

4 files changed

+306
-1
lines changed

4 files changed

+306
-1
lines changed

go.mod

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,13 @@ module go.rtnl.ai/radish
22

33
go 1.25.6
44

5-
require go.rtnl.ai/x v1.10.0
5+
require (
6+
github.com/stretchr/testify v1.11.1
7+
go.rtnl.ai/x v1.10.0
8+
)
9+
10+
require (
11+
github.com/davecgh/go-spew v1.1.1 // indirect
12+
github.com/pmezard/go-difflib v1.0.0 // indirect
13+
gopkg.in/yaml.v3 v3.0.1 // indirect
14+
)

go.sum

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,12 @@
1+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
4+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
5+
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
6+
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
17
go.rtnl.ai/x v1.10.0 h1:NaFXZkhLlAFygevskDwWYMlu2zF3U29j2cmEcAcfJl4=
28
go.rtnl.ai/x v1.10.0/go.mod h1:ciQ9PaXDtZDznzBrGDBV2yTElKX3aJgtQfi6V8613bo=
9+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
10+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
11+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
12+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

status/status.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package status
2+
3+
import (
4+
"database/sql/driver"
5+
"encoding/json"
6+
"fmt"
7+
"strings"
8+
)
9+
10+
type Status uint8
11+
12+
const (
13+
StatusUnknown Status = iota
14+
StatusPending
15+
StatusRunning
16+
StatusSuccess
17+
StatusFailure
18+
StatusRetry
19+
StatusRevoked
20+
StatusScheduled
21+
22+
// The terminator is used to determine the last value of the enum.
23+
statusTerminator
24+
)
25+
26+
var statusNames = [8]string{
27+
"unknown",
28+
"pending",
29+
"running",
30+
"success",
31+
"failure",
32+
"retry",
33+
"revoked",
34+
"scheduled",
35+
}
36+
37+
func Parse(s any) (Status, error) {
38+
switch v := s.(type) {
39+
case string:
40+
s = strings.TrimSpace(strings.ToLower(v))
41+
if s == "" {
42+
return StatusUnknown, nil
43+
}
44+
45+
for i, name := range statusNames {
46+
if name == s {
47+
return Status(i), nil
48+
}
49+
}
50+
51+
return StatusUnknown, fmt.Errorf("invalid status: %q", s)
52+
case float64:
53+
if v < 0 || v > float64(^uint8(0)) || v != float64(uint8(v)) {
54+
return StatusUnknown, fmt.Errorf("cannot parse %v to Status", v)
55+
}
56+
return Status(uint8(v)), nil
57+
case uint8:
58+
return Status(v), nil
59+
case Status:
60+
return v, nil
61+
default:
62+
return StatusUnknown, fmt.Errorf("cannot parse %T to Status", s)
63+
}
64+
}
65+
66+
func (s Status) String() string {
67+
if s >= statusTerminator {
68+
return statusNames[0]
69+
}
70+
return statusNames[s]
71+
}
72+
73+
func (s Status) Uint8() uint8 {
74+
return uint8(s)
75+
}
76+
77+
//============================================================================
78+
// JSON Serialization and Deserialization
79+
//============================================================================
80+
81+
func (s Status) MarshalJSON() ([]byte, error) {
82+
return json.Marshal(s.String())
83+
}
84+
85+
func (s *Status) UnmarshalJSON(data []byte) (err error) {
86+
var v any
87+
if err := json.Unmarshal(data, &v); err != nil {
88+
return err
89+
}
90+
91+
if *s, err = Parse(v); err != nil {
92+
return err
93+
}
94+
return nil
95+
}
96+
97+
//===========================================================================
98+
// Database Interaction
99+
//===========================================================================
100+
101+
func (s *Status) Scan(src interface{}) (err error) {
102+
switch x := src.(type) {
103+
case nil:
104+
return nil
105+
case string:
106+
*s, err = Parse(x)
107+
return err
108+
case []byte:
109+
*s, err = Parse(string(x))
110+
return err
111+
default:
112+
return fmt.Errorf("cannot scan %T into a status", src)
113+
}
114+
}
115+
116+
func (s Status) Value() (driver.Value, error) {
117+
return s.String(), nil
118+
}

status/status_test.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package status_test
2+
3+
import (
4+
"encoding/json"
5+
"math/rand"
6+
"strings"
7+
"testing"
8+
"unicode"
9+
10+
"github.com/stretchr/testify/require"
11+
"go.rtnl.ai/radish/status"
12+
)
13+
14+
var (
15+
defaultInvalid = []any{"foo", "123", "INVALID", 257, -1, 3.14, struct{}{}, true, false}
16+
statusValues = []status.Status{
17+
status.StatusUnknown,
18+
status.StatusPending,
19+
status.StatusRunning,
20+
status.StatusSuccess,
21+
status.StatusFailure,
22+
status.StatusRetry,
23+
status.StatusRevoked,
24+
status.StatusScheduled,
25+
}
26+
statusStrings = []string{
27+
"unknown",
28+
"pending",
29+
"running",
30+
"success",
31+
"failure",
32+
"retry",
33+
"revoked",
34+
"scheduled",
35+
}
36+
)
37+
38+
const (
39+
dbVarcharLimit = 16
40+
)
41+
42+
func TestString(t *testing.T) {
43+
for i, enum := range statusValues {
44+
require.Equal(t, statusStrings[i], enum.String(), "expected status to have string representation %q, got %q", statusStrings[i], enum.String())
45+
}
46+
47+
// Test Zero Values
48+
zero := status.Status(0)
49+
require.Equal(t, status.StatusUnknown.String(), zero.String(), "expected status to have string representation \"unknown\" for zero value")
50+
51+
empty, err := status.Parse("")
52+
require.NoError(t, err, "failed to parse empty string for status")
53+
require.Equal(t, status.StatusUnknown.String(), empty.String(), "expected status to have string representation \"unknown\" for empty string not %q", empty.String())
54+
}
55+
56+
func TestStringBounds(t *testing.T) {
57+
max := uint8(0)
58+
min := uint8(255)
59+
60+
for i := range statusValues {
61+
if uint8(i) > max {
62+
max = uint8(i)
63+
}
64+
if uint8(i) < min {
65+
min = uint8(i)
66+
}
67+
}
68+
69+
above := status.Status(max + 1)
70+
require.Equal(t, status.StatusUnknown.String(), above.String(), "expected status to have string representation \"unknown\" for unknown value")
71+
72+
// Test zero value
73+
if min > 0 {
74+
zero := status.Status(0)
75+
require.Equal(t, status.StatusUnknown.String(), zero.String(), "expected status to have string representation \"unknown\" for zero value")
76+
}
77+
}
78+
79+
func TestParse(t *testing.T) {
80+
t.Run("Valid", func(t *testing.T) {
81+
makeTestCases := func(i int, enum status.Status) []any {
82+
tests := make([]any, 0, 8)
83+
tests = append(tests, statusStrings[i])
84+
tests = append(tests, enum.Uint8())
85+
tests = append(tests, float64(enum.Uint8()))
86+
tests = append(tests, enum)
87+
tests = append(tests, strings.ToUpper(statusStrings[i]), strings.ToLower(statusStrings[i]))
88+
tests = append(tests, mixedCase(statusStrings[i]))
89+
90+
return tests
91+
}
92+
93+
for i, enum := range statusValues {
94+
tests := makeTestCases(i, enum)
95+
for _, input := range tests {
96+
actual, err := status.Parse(input)
97+
require.NoError(t, err, "failed to parse valid status value %#v", input)
98+
require.Equal(t, enum, actual, "expected parsing valid status value %#v", input)
99+
}
100+
}
101+
})
102+
103+
t.Run("Invalid", func(t *testing.T) {
104+
for _, str := range defaultInvalid {
105+
actual, err := status.Parse(str)
106+
require.Error(t, err, "expected parsing invalid status value %q to error", str)
107+
require.Equal(t, uint8(0), actual.Uint8(), "expected parsing invalid status value %q to return zero value, got %d", str, actual.Uint8())
108+
}
109+
})
110+
}
111+
112+
func TestJSON(t *testing.T) {
113+
t.Run("Serialization", func(t *testing.T) {
114+
for _, enum := range statusValues {
115+
orig := status.Status(enum.Uint8())
116+
data, err := json.Marshal(orig)
117+
require.NoError(t, err, "failed to marshal status value %q", orig.String())
118+
119+
cmp := status.Status(0)
120+
err = json.Unmarshal(data, &cmp)
121+
require.NoError(t, err, "failed to unmarshal status value %q", orig.String())
122+
require.Equal(t, orig, cmp, "unmarshaled status does not match original")
123+
}
124+
})
125+
126+
t.Run("Errors", func(t *testing.T) {
127+
inputs := make([][]byte, 0, len(defaultInvalid)+2)
128+
inputs = append(inputs, []byte(`"unquoted`), []byte(`{"missing":}`)) // add bad JSON inputs
129+
130+
// Add parse errors
131+
for _, v := range defaultInvalid {
132+
if data, err := json.Marshal(v); err == nil {
133+
inputs = append(inputs, data)
134+
}
135+
}
136+
137+
for _, data := range inputs {
138+
cmp := status.Status(0)
139+
err := json.Unmarshal(data, &cmp)
140+
require.Error(t, err, "expected unmarshaling invalid status JSON value %q to error", string(data))
141+
require.Equal(t, uint8(0), cmp.Uint8(), "expected unmarshaling invalid status JSON value %q to return zero value, got %d", string(data), cmp.Uint8())
142+
}
143+
})
144+
}
145+
146+
func TestDatabase(t *testing.T) {
147+
// TODO: implement scan and value tests
148+
t.Run("VARCHAR", func(t *testing.T) {
149+
// Ensure that all string representations are less than or equal to the db VARCHAR limit
150+
for _, enum := range statusValues {
151+
require.LessOrEqual(t, len(enum.String()), dbVarcharLimit, "expected status value %q to be less than or equal to %d characters", enum.String(), dbVarcharLimit)
152+
}
153+
})
154+
}
155+
156+
func mixedCase(s string) string {
157+
b := make([]rune, len(s))
158+
for i, r := range s {
159+
// Flip a coin and make the character upper or lower case
160+
if rand.Intn(2) == 0 {
161+
r = unicode.ToLower(r)
162+
} else {
163+
r = unicode.ToUpper(r)
164+
}
165+
b[i] = r
166+
}
167+
return string(b)
168+
}

0 commit comments

Comments
 (0)