From 8d9828d3909366b234e3ae73367de61f99943307 Mon Sep 17 00:00:00 2001 From: Ambrose Chua Date: Mon, 9 Nov 2020 00:27:16 +0800 Subject: [PATCH] Add zone parsing, and test code separately --- .github/workflows/build.yml | 21 ++++++-- Makefile | 2 +- app.go | 2 +- app_test.go | 2 +- mime_test.go | 4 ++ url_test.go | 33 +++++++----- zone.go | 78 +++++++++++++++++++++++++++ zone_test.go | 102 ++++++++++++++++++++++++++++++++++++ 8 files changed, 224 insertions(+), 20 deletions(-) create mode 100644 zone.go create mode 100644 zone_test.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6f84d9a..42c2aa5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,6 +8,22 @@ on: jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + + - name: Set up Go 1.x + uses: actions/setup-go@v2 + with: + go-version: ^1.15 + + - name: Check out code into the Go module directory + uses: actions/checkout@v2 + + - name: Test code + run: make test + vet: name: Vet runs-on: ubuntu-latest @@ -68,10 +84,7 @@ jobs: uses: actions/checkout@v2 - name: Build - run: make - - - name: Test - run: make test + run: make TAGS=production # vim: set et ts=2 sw=2: diff --git a/Makefile b/Makefile index 6cfafd0..e66c250 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ datetime: *.go .PHONY: test test: - $(GO) test -v + $(GO) test -cover -bench=. -v DATASETS = \ diff --git a/app.go b/app.go index a3b9593..47bdc68 100644 --- a/app.go +++ b/app.go @@ -16,7 +16,7 @@ type Datetime struct { } // NewDatetime creates an application instance. It assumes certain resources -// like templates and data exist. +// like templates and data exist func NewDatetime() (*Datetime, error) { // Data tmpl, err := template.ParseGlob("templates/*") diff --git a/app_test.go b/app_test.go index c920016..e53cf3e 100644 --- a/app_test.go +++ b/app_test.go @@ -9,7 +9,7 @@ import ( func TestChooseTemplate(t *testing.T) { tmpl, err := template.ParseGlob("templates/*") if err != nil { - t.Errorf("unable to load templates: %v", err) + panic(err) } app := &Datetime{tmpl: tmpl} diff --git a/mime_test.go b/mime_test.go index a48000e..e23d4af 100644 --- a/mime_test.go +++ b/mime_test.go @@ -11,6 +11,10 @@ func TestChooseResponseType(t *testing.T) { } r = chooseResponseType("application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3") if r != responseAny { + t.Errorf("expecting any, got %v", r) + } + r = chooseResponseType("text/plain;q=,text/html") + if r != responseHTML { t.Errorf("expecting html, got %v", r) } } diff --git a/url_test.go b/url_test.go index 4ee4f11..2d064a0 100644 --- a/url_test.go +++ b/url_test.go @@ -21,7 +21,7 @@ func TestURLParse(t *testing.T) { u := mustURLParse("http://test/2020-06-02T14:00+08:00/Singapore,Malaysia") got, err := ParseRequest(u) if err != nil { - t.Errorf("mismatch: got error %v", err) + t.Errorf("got error %v", err) return } want := Request{ @@ -29,13 +29,13 @@ func TestURLParse(t *testing.T) { []string{"Singapore", "Malaysia"}, } if !cmp.Equal(got, want) { - t.Errorf("mismatch: \n%v", cmp.Diff(got, want)) + t.Errorf("%v", cmp.Diff(got, want)) } u = mustURLParse("http://test/2019-04-30T18:00:00Z/Nowhere") got, err = ParseRequest(u) if err != nil { - t.Errorf("mismatch: got error %v", err) + t.Errorf("got error %v", err) return } want = Request{ @@ -43,7 +43,7 @@ func TestURLParse(t *testing.T) { []string{"Nowhere"}, } if !cmp.Equal(got, want) { - t.Errorf("mismatch: \n%v", cmp.Diff(got, want)) + t.Errorf("%v", cmp.Diff(got, want)) } } @@ -51,31 +51,38 @@ func TestURLParseFail(t *testing.T) { u := mustURLParse("http://test/2002-08-30T14:00+06:00/") _, err := ParseRequest(u) if !errors.Is(err, ErrMissingComponent) { - t.Errorf("mismatch: got error %v, want error %v", err, ErrMissingComponent) - return + t.Errorf("got error %v, want error %v", err, ErrMissingComponent) } u = mustURLParse("http://test/") _, err = ParseRequest(u) if !errors.Is(err, ErrMissingComponent) { - t.Errorf("mismatch: got error %v, want error %v", err, ErrMissingComponent) - return + t.Errorf("got error %v, want error %v", err, ErrMissingComponent) + } + + u = mustURLParse("http://test") + _, err = ParseRequest(u) + if !errors.Is(err, ErrMissingComponent) { + t.Errorf("got error %v, want error %v", err, ErrMissingComponent) + } + + u = mustURLParse("http://test/hi/hi/hi") + _, err = ParseRequest(u) + if !errors.Is(err, ErrTooManyComponent) { + t.Errorf("got error %v, want error %v", err, ErrTooManyComponent) } u = mustURLParse("http://test/2000-01-13T00:00Z08:00/hi") _, err = ParseRequest(u) _, isParseError := err.(*time.ParseError) if !isParseError { - t.Errorf("mismatch: got error %v, want time.ParseError", err) - return + t.Errorf("got error %v, want time.ParseError", err) } u = mustURLParse("http://test/2000-01-13 00:00+08:00/hi") _, err = ParseRequest(u) _, isParseError = err.(*time.ParseError) if !isParseError { - t.Errorf("mismatch: got error %v, want time.ParseError", err) - return + t.Errorf("got error %v, want time.ParseError", err) } - } diff --git a/zone.go b/zone.go new file mode 100644 index 0000000..8952a9b --- /dev/null +++ b/zone.go @@ -0,0 +1,78 @@ +package main + +import ( + "errors" + "regexp" + "strconv" + "time" + + "github.com/serverwentdown/datetime.link/data" +) + +// ErrZoneNotFound is thrown when a zone string has no match +var ErrZoneNotFound = errors.New("zone not found") + +// ErrZoneOffsetInvalid is thrown when a zone string is an invalid zone offset +var ErrZoneOffsetInvalid = errors.New("offset zone invalid") + +var zoneOffsetRegexp = regexp.MustCompile(`^[+-][0-9]{2}:[0-9]{2}$`) + +// SearchCities looks up a city by it's reference +func SearchCities(cities map[string]*data.City, city string) (*data.City, error) { + // For now, simple map read will do + if city, ok := cities[city]; ok { + return city, nil + } + return nil, ErrZoneNotFound +} + +// ParseZoneOffset parses a zone string into a time.Location +func ParseZoneOffset(zone string) (*time.Location, error) { + if !zoneOffsetRegexp.MatchString(zone) { + return nil, ErrZoneOffsetInvalid + } + // Assume that if it satisfies the regex, it satisfies the length and won't + // fail to parse + d := 0 + if zone[0] == '+' { + d = 1 + } + if zone[0] == '-' { + d = -1 + } + h, _ := strconv.ParseUint(zone[1:1+2], 10, 64) + // Allow hour offsets greater that 24 + m, _ := strconv.ParseUint(zone[1+3:1+3+2], 10, 64) + if m >= 60 { + return nil, ErrZoneOffsetInvalid + } + return time.FixedZone("UTC"+zone, d*(int(h)*60*60+int(m)*60)), nil +} + +// Zone represents any form of zone offset: this could be a city or a fixed +// offset from UTC +type Zone struct { + // City is optional + City *data.City + // Offset is optional + Offset *time.Location +} + +// ResolveZone resolves a zone string into a Zone +func ResolveZone(cities map[string]*data.City, zone string) (Zone, error) { + // Try parsing as a zone offset + offset, err := ParseZoneOffset(zone) + if err == nil { + return Zone{ + Offset: offset, + }, nil + } + // Parse as a city + city, err := SearchCities(cities, zone) + if err == nil { + return Zone{ + City: city, + }, nil + } + return Zone{}, err +} diff --git a/zone_test.go b/zone_test.go new file mode 100644 index 0000000..bb9f40a --- /dev/null +++ b/zone_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "testing" + "time" + + "github.com/serverwentdown/datetime.link/data" +) + +func TestParseZoneOffset(t *testing.T) { + loc, err := ParseZoneOffset("+08:00") + if err != nil { + t.Errorf("want error %v, got error %v", nil, err) + } else { + time := time.Date(2020, time.November, 8, 23, 9, 0, 0, loc).Unix() + want := int64(1604848140) + if time != want { + t.Errorf("got %d, want %d", time, want) + } + } + + loc, err = ParseZoneOffset("-00:30") + if err != nil { + t.Errorf("want error %v, got error %v", nil, err) + } else { + time := time.Date(2020, time.November, 8, 14, 39, 0, 0, loc).Unix() + want := int64(1604848140) + if time != want { + t.Errorf("got %d, want %d", time, want) + } + } + + loc, err = ParseZoneOffset("-0030") + if err != ErrZoneOffsetInvalid { + t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err) + } + + loc, err = ParseZoneOffset("00:30") + if err != ErrZoneOffsetInvalid { + t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err) + } + + loc, err = ParseZoneOffset("+08:60") + if err != ErrZoneOffsetInvalid { + t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err) + } + + loc, err = ParseZoneOffset("+08:-6") + if err != ErrZoneOffsetInvalid { + t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err) + } +} + +func TestSearchCities(t *testing.T) { + cities, err := data.ReadCities() + if err != nil { + panic(err) + } + + city, err := SearchCities(cities, "Singapore-SG") + if err != nil { + t.Errorf("want error %v, got error %v", nil, err) + } + wantName := "Singapore" + wantZone := "Asia/Singapore" + if city.Name != wantName || city.Timezone != wantZone { + t.Errorf("want %v %v, got %v", wantName, wantZone, city) + } + + city, err = SearchCities(cities, "Yuzhno_Sakhalinsk-Sakhalin_Oblast-RU") + if err != nil { + t.Errorf("want error %v, got error %v", nil, err) + } + wantName = "Yuzhno-Sakhalinsk" + wantZone = "Asia/Sakhalin" + if city.Name != wantName || city.Timezone != wantZone { + t.Errorf("want %v %v, got %v", wantName, wantZone, city) + } + + _, err = SearchCities(cities, "Nowhere") + if err != ErrZoneNotFound { + t.Errorf("want error %v, got error %v", ErrZoneNotFound, err) + } +} + +func BenchmarkReadCities(b *testing.B) { + // This does take quite a while + for i := 0; i < b.N; i++ { + _, _ = data.ReadCities() + } +} + +func BenchmarkSearchCities(b *testing.B) { + cities, err := data.ReadCities() + if err != nil { + panic(err) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = SearchCities(cities, "Yuzhno_Sakhalinsk-Sakhalin_Oblast-RU") + } +}