diff --git a/claat/types/meta.go b/claat/types/codelab.go similarity index 67% rename from claat/types/meta.go rename to claat/types/codelab.go index 75698a178..1f55a0c05 100644 --- a/claat/types/meta.go +++ b/claat/types/codelab.go @@ -16,7 +16,6 @@ package types import ( - "bytes" "time" "github.com/googlecodelabs/tools/claat/nodes" @@ -42,22 +41,6 @@ type Meta struct { URL string `json:"url"` // Legacy ID; TODO: remove } -// Context is an export context. -// It is defined in this package so that it can be used by both cli and a server. -type Context struct { - Env string `json:"environment"` // Current export environment - Format string `json:"format"` // Output format, e.g. "html" - Prefix string `json:"prefix,omitempty"` // Assets URL prefix for HTML-based formats - MainGA string `json:"mainga,omitempty"` // Global Google Analytics ID - Updated *ContextTime `json:"updated,omitempty"` // Last update timestamp -} - -// ContextMeta is a composition of export context and meta data. -type ContextMeta struct { - Context - Meta -} - // Codelab is a top-level structure containing metadata and codelab steps. type Codelab struct { Meta @@ -85,33 +68,3 @@ type Step struct { Duration time.Duration // Duration Content *nodes.ListNode // Root node of the step nodes tree } - -// ContextTime is codelab metadata timestamp. -// It can be of "YYYY-MM-DD" or RFC3339 formats but marshaling -// always uses RFC3339 format. -type ContextTime time.Time - -// MarshalJSON implements Marshaler interface. -func (ct ContextTime) MarshalJSON() ([]byte, error) { - v := time.Time(ct).Format(time.RFC3339) - b := make([]byte, len(v)+2) - b[0] = '"' - b[len(b)-1] = '"' - copy(b[1:], v) - return b, nil -} - -// UnmarshalJSON implements Unmarshaler interface. -// Accepted format is "YYYY-MM-DD" or RFC3339. -func (ct *ContextTime) UnmarshalJSON(b []byte) error { - b = bytes.Trim(b, `"`) - t, err := time.Parse(time.RFC3339, string(b)) - if err != nil { - t, err = time.Parse("2006-01-02", string(b)) - } - if err != nil { - return err - } - *ct = ContextTime(t) - return nil -} diff --git a/claat/types/codelab_test.go b/claat/types/codelab_test.go new file mode 100644 index 000000000..6ab17a937 --- /dev/null +++ b/claat/types/codelab_test.go @@ -0,0 +1,28 @@ +package types + +import ( + "testing" +) + +func TestNewCodelab(t *testing.T) { + c := NewCodelab() + + if c.Extra == nil { + t.Errorf("NewCodelab() failed to initialize Extra") + } +} + +func TestNewStep(t *testing.T) { + c := NewCodelab() + s := c.NewStep("foobar") + + if c.Steps[len(c.Steps)-1] != s { + t.Errorf(`Codelab.NewStep("foobar") did not return added step`) + } + if s.Title != "foobar" { + t.Errorf(`Codelab.NewStep("foobar") got title %q, want "foobar"`, s.Title) + } + if s.Content == nil { + t.Errorf(`Codelab.NewStep("foobar") did not initialize s.Content`) + } +} diff --git a/claat/types/context.go b/claat/types/context.go new file mode 100644 index 000000000..ba6ea4392 --- /dev/null +++ b/claat/types/context.go @@ -0,0 +1,53 @@ +package types + +import ( + "bytes" + "time" +) + +// Context is an export context. +// It is defined in this package so that it can be used by both cli and a server. +type Context struct { + Env string `json:"environment"` // Current export environment + Format string `json:"format"` // Output format, e.g. "html" + Prefix string `json:"prefix,omitempty"` // Assets URL prefix for HTML-based formats + MainGA string `json:"mainga,omitempty"` // Global Google Analytics ID + Updated *ContextTime `json:"updated,omitempty"` // Last update timestamp +} + +// ContextMeta is a composition of export context and meta data. +type ContextMeta struct { + Context + Meta +} + +// ContextTime is a wrapper around time.Time so we can implement JSON marshalling. +type ContextTime time.Time + +// MarshalJSON implements Marshaler interface. +// The output format is RFC3339. +func (ct ContextTime) MarshalJSON() ([]byte, error) { + v := time.Time(ct).Format(time.RFC3339) + b := make([]byte, len(v)+2) + b[0] = '"' + b[len(b)-1] = '"' + copy(b[1:], v) + return b, nil +} + +// UnmarshalJSON implements Unmarshaler interface. +// Accepted formats: +// - RFC3339 +// - YYYY-MM-DD +func (ct *ContextTime) UnmarshalJSON(b []byte) error { + b = bytes.Trim(b, `"`) + t, err := time.Parse(time.RFC3339, string(b)) + if err != nil { + t, err = time.Parse("2006-01-02", string(b)) + } + if err != nil { + return err + } + *ct = ContextTime(t) + return nil +} diff --git a/claat/types/context_test.go b/claat/types/context_test.go new file mode 100644 index 000000000..45bc3b382 --- /dev/null +++ b/claat/types/context_test.go @@ -0,0 +1,132 @@ +package types + +import ( + "testing" + "time" + + "github.com/google/go-cmp/cmp" +) + +func TestContextTimeMarshalJSON(t *testing.T) { + tests := []struct { + name string + in ContextTime + out []byte + }{ + { + name: "Epoch", + in: ContextTime(time.Unix(0, 0)), + out: []byte(`"1970-01-01T00:00:00Z"`), + }, + { + name: "WithTimeZone", + in: ContextTime(time.Unix(1629497889, 0).In(time.FixedZone("San Francisco (DST)", -7*60*60))), + out: []byte(`"2021-08-20T15:18:09-07:00"`), + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + out, err := tc.in.MarshalJSON() + if err != nil { + t.Errorf("ContextTime.MarshalJSON() = %+v , want %+q", err, tc.out) + return + } + if diff := cmp.Diff(tc.out, out); diff != "" { + t.Errorf("ContextTime.MarshalJSON got diff (-want +got): %s", diff) + return + } + }) + } +} + +func TestContextTimeUnmarshalJSON(t *testing.T) { + tests := []struct { + name string + in []byte + // ContextTime sets internal fields we don't care about -- easier to compare Unix times. + out int64 + ok bool + }{ + { + name: "RFC3339", + in: []byte(`"2021-08-20T22:35:40Z"`), + out: 1629498940, + ok: true, + }, + { + name: "YYYY-MM-DD", + in: []byte(`"2021-08-20"`), + out: 1629417600, + ok: true, + }, + // TODO should wrong quotes be accepted? + { + name: "RFC3339NoQuotes", + in: []byte(`2021-08-20T22:35:40Z`), + out: 1629498940, + ok: true, + }, + { + name: "YYYY-MM-DDNoQuotes", + in: []byte(`2021-08-20`), + out: 1629417600, + ok: true, + }, + { + name: "RFC3339OnlyOpeningQuote", + in: []byte(`"2021-08-20T22:35:40Z`), + out: 1629498940, + ok: true, + }, + { + name: "YYYY-MM-DDOnlyOpeningQuote", + in: []byte(`"2021-08-20`), + out: 1629417600, + ok: true, + }, + { + name: "RFC3339OnlyClosingQuote", + in: []byte(`2021-08-20T22:35:40Z"`), + out: 1629498940, + ok: true, + }, + { + name: "YYYY-MM-DDOnlyClosingQuote", + in: []byte(`2021-08-20"`), + out: 1629417600, + ok: true, + }, + { + name: "Invalid", + in: []byte("foobar"), + }, + { + name: "BrokenRFC3339", + in: []byte(`"2021-08-2022:35:40Z"`), + }, + { + name: "BrokenYYYY-MM-DD", + in: []byte(`"2021-13-20"`), + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ct := &ContextTime{} + err := ct.UnmarshalJSON(tc.in) + if tc.ok && err != nil { + t.Errorf("ContextTime.UnmarshalJSON(%+v) got err %+v, want %+v", tc.in, err, tc.out) + return + } + if !tc.ok && err == nil { + t.Errorf("ContextTime.UnmarshalJSON(%+v) got %+v, want err", tc.in, ct) + return + } + // ContextTime sets internal fields that we don't care about that makes cmp.Diff undesirable for comparison here. + gotUnixTime := time.Time(*ct).Unix() + if tc.ok && gotUnixTime != tc.out { + t.Errorf("ContextTime.UnmarshalJSON(%+v) got time %d, want %d", tc.in, gotUnixTime, tc.out) + return + } + }) + } +}