diff --git a/app_test.go b/app_test.go deleted file mode 100644 index dd14ff7..0000000 --- a/app_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package main - -import ( - "fmt" - "html/template" - "testing" -) - -func TestChooseTemplate(t *testing.T) { - tmpl, err := template.New("templates").Funcs(templateFuncs).ParseGlob("templates/*") - if err != nil { - panic(err) - } - app := &Datetime{tmpl: tmpl} - - type chooseTest struct { - accept string - acceptable bool - contentType string - template string - } - tests := []chooseTest{ - {"text/html", true, "text/html", "index.html"}, - {"text/html;q=0.9,text/plain", true, "text/plain", "index.txt"}, - {"image/png", false, "", ""}, - {"*/*", true, "text/plain", "index.txt"}, - } - - for _, test := range tests { - tmpl, contentType, acceptable := app.chooseTemplate(test.accept, "index") - fn := fmt.Sprintf("chooseTemplate(\"%s\")", test.accept) - if contentType != test.contentType { - t.Errorf("%s; contentType = %v; wanted %v", fn, contentType, test.contentType) - } - if acceptable != test.acceptable { - t.Errorf("%s; acceptable = %v; wanted %v", fn, acceptable, test.acceptable) - } - if tmpl != app.tmpl.Lookup(test.template) { - t.Errorf("%s; tmpl = %v; wanted template for %v", fn, tmpl.Name(), test.template) - } - } -} diff --git a/error.go b/apperror.go similarity index 100% rename from error.go rename to apperror.go diff --git a/template.go b/template.go index 29669aa..b08f7d3 100644 --- a/template.go +++ b/template.go @@ -7,40 +7,6 @@ import ( "go.uber.org/zap" ) -var templateFuncs = map[string]interface{}{ - "statusText": templateFuncStatusText, - "thisIsSafe": templateFuncThisIsSafe, - // Formatting - "formatOffset": templateFuncFormatOffset, - // Logic - "resolveZone": templateFuncResolveZone, -} - -func templateFuncStatusText(s int) string { - return http.StatusText(s) -} -func templateFuncThisIsSafe(s string) template.HTML { - return template.HTML(s) -} - -func templateFuncFormatOffset(offset int) string { - return FormatZoneOffset(offset) -} - -// ResolvedZone holds a resolved zone or an error -type ResolvedZone struct { - Zone - Error error -} - -func templateFuncResolveZone(app Datetime, zone string) ResolvedZone { - z, err := ResolveZone(app.cities, zone) - if err != nil { - l.Debug("unable to resolve zone", zap.Reflect("zone", zone), zap.Error(err)) - } - return ResolvedZone{z, err} -} - // loadTemplate returns a matching template for the request. It also causes an // error if the template is not found or the Accept parameters are incorrect. func (app Datetime) loadTemplate(name string, w http.ResponseWriter, req *http.Request) *template.Template { diff --git a/template_test.go b/template_test.go index b606c7c..dd14ff7 100644 --- a/template_test.go +++ b/template_test.go @@ -1,27 +1,42 @@ package main import ( + "fmt" + "html/template" "testing" ) -func TestTemplateFuncFormatOffset(t *testing.T) { - want, got := "+06:06", templateFuncFormatOffset(6*60*60+6*60) - if want != got { - t.Fatalf("got offset %v, want offset %v", got, want) +func TestChooseTemplate(t *testing.T) { + tmpl, err := template.New("templates").Funcs(templateFuncs).ParseGlob("templates/*") + if err != nil { + panic(err) + } + app := &Datetime{tmpl: tmpl} + + type chooseTest struct { + accept string + acceptable bool + contentType string + template string + } + tests := []chooseTest{ + {"text/html", true, "text/html", "index.html"}, + {"text/html;q=0.9,text/plain", true, "text/plain", "index.txt"}, + {"image/png", false, "", ""}, + {"*/*", true, "text/plain", "index.txt"}, } - want, got = "-12:15", templateFuncFormatOffset(-(12*60*60 + 15*60)) - if want != got { - t.Fatalf("got offset %v, want offset %v", got, want) - } - - want, got = "\u00B100:00", templateFuncFormatOffset(-(0*60*60 + 0*60)) - if want != got { - t.Fatalf("got offset %v, want offset %v", got, want) - } - - want, got = "+00:01", templateFuncFormatOffset(0*60*60+1*60) - if want != got { - t.Fatalf("got offset %v, want offset %v", got, want) + for _, test := range tests { + tmpl, contentType, acceptable := app.chooseTemplate(test.accept, "index") + fn := fmt.Sprintf("chooseTemplate(\"%s\")", test.accept) + if contentType != test.contentType { + t.Errorf("%s; contentType = %v; wanted %v", fn, contentType, test.contentType) + } + if acceptable != test.acceptable { + t.Errorf("%s; acceptable = %v; wanted %v", fn, acceptable, test.acceptable) + } + if tmpl != app.tmpl.Lookup(test.template) { + t.Errorf("%s; tmpl = %v; wanted template for %v", fn, tmpl.Name(), test.template) + } } } diff --git a/templatehelpers.go b/templatehelpers.go new file mode 100644 index 0000000..4381922 --- /dev/null +++ b/templatehelpers.go @@ -0,0 +1,42 @@ +package main + +import ( + "html/template" + "net/http" + + "go.uber.org/zap" +) + +var templateFuncs = map[string]interface{}{ + "statusText": templateFuncStatusText, + "thisIsSafe": templateFuncThisIsSafe, + // Formatting + "formatOffset": templateFuncFormatOffset, + // Logic + "resolveZone": templateFuncResolveZone, +} + +func templateFuncStatusText(s int) string { + return http.StatusText(s) +} +func templateFuncThisIsSafe(s string) template.HTML { + return template.HTML(s) +} + +func templateFuncFormatOffset(offset int) string { + return FormatZoneOffset(offset) +} + +// ResolvedZone holds a resolved zone or an error +type ResolvedZone struct { + Zone + Error error +} + +func templateFuncResolveZone(app Datetime, zone string) ResolvedZone { + z, err := ResolveZone(app.cities, zone) + if err != nil { + l.Debug("unable to resolve zone", zap.Reflect("zone", zone), zap.Error(err)) + } + return ResolvedZone{z, err} +} diff --git a/zone.go b/zone.go index 44cad91..74ce7ee 100644 --- a/zone.go +++ b/zone.go @@ -1,76 +1,12 @@ package main import ( - "errors" - "fmt" - "regexp" - "strconv" "time" "github.com/serverwentdown/datetime.link/data" "go.uber.org/zap" ) -// 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") - -const zoneHour = 60 * 60 -const zoneMinute = 60 - -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) (int, error) { - if !zoneOffsetRegexp.MatchString(zone) { - return 0, 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 0, ErrZoneOffsetInvalid - } - offset := d * (int(h)*zoneHour + int(m)*zoneMinute) - return offset, nil -} - -// FormatZoneOffset formats an offset into a string -func FormatZoneOffset(offset int) string { - neg := offset < 0 - s := '+' - if neg { - s = '-' - offset = -offset - } - if offset == 0 { - return "\u00B100:00" - } - h := offset / zoneHour - m := (offset % zoneHour) / zoneMinute - return fmt.Sprintf("%c%02d:%02d", s, h, m) -} - // Zone represents any form of zone offset: this could be a city or a fixed // offset from UTC type Zone struct { diff --git a/zone_test.go b/zone_test.go index dc8457b..aebba13 100644 --- a/zone_test.go +++ b/zone_test.go @@ -7,104 +7,6 @@ import ( "github.com/serverwentdown/datetime.link/data" ) -func TestParseZoneOffset(t *testing.T) { - offset, err := ParseZoneOffset("+08:00") - if err != nil { - t.Errorf("want error %v, got error %v", nil, err) - } else { - want := 8*60*60 + 0*60 - if offset != want { - t.Errorf("got %d, want %d", offset, want) - } - } - - offset, err = ParseZoneOffset("-01:30") - if err != nil { - t.Errorf("want error %v, got error %v", nil, err) - } else { - want := -(1*60*60 + 30*60) - if offset != want { - t.Errorf("got %d, want %d", offset, want) - } - } - - _, err = ParseZoneOffset("-0030") - if err != ErrZoneOffsetInvalid { - t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err) - } - - _, err = ParseZoneOffset("00:30") - if err != ErrZoneOffsetInvalid { - t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err) - } - - _, err = ParseZoneOffset("+08:60") - if err != ErrZoneOffsetInvalid { - t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err) - } - - _, err = ParseZoneOffset("+08:-6") - if err != ErrZoneOffsetInvalid { - t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err) - } - - offset, err = ParseZoneOffset("+08:00") - if err != nil { - t.Errorf("want error %v, got error %v", nil, err) - } else { - loc := time.FixedZone("UTC "+FormatZoneOffset(offset), offset) - 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) - } - } - - offset, err = ParseZoneOffset("-00:30") - if err != nil { - t.Errorf("want error %v, got error %v", nil, err) - } else { - loc := time.FixedZone("UTC "+FormatZoneOffset(offset), offset) - 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) - } - } -} - -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 TestResolveZone(t *testing.T) { cities, err := data.ReadCities() if err != nil { @@ -147,21 +49,3 @@ func TestResolveZone(t *testing.T) { 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") - } -} diff --git a/zonecity.go b/zonecity.go new file mode 100644 index 0000000..290d4b6 --- /dev/null +++ b/zonecity.go @@ -0,0 +1,19 @@ +package main + +import ( + "errors" + + "github.com/serverwentdown/datetime.link/data" +) + +// ErrZoneNotFound is thrown when a zone string has no match +var ErrZoneNotFound = errors.New("zone not found") + +// 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 +} diff --git a/zonecity_test.go b/zonecity_test.go new file mode 100644 index 0000000..35da5dd --- /dev/null +++ b/zonecity_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "testing" + + "github.com/serverwentdown/datetime.link/data" +) + +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") + } +} diff --git a/zoneoffset.go b/zoneoffset.go new file mode 100644 index 0000000..687b90a --- /dev/null +++ b/zoneoffset.go @@ -0,0 +1,56 @@ +package main + +import ( + "errors" + "fmt" + "regexp" + "strconv" +) + +// ErrZoneOffsetInvalid is thrown when a zone string is an invalid zone offset +var ErrZoneOffsetInvalid = errors.New("offset zone invalid") + +const zoneHour = 60 * 60 +const zoneMinute = 60 + +var zoneOffsetRegexp = regexp.MustCompile(`^[+-][0-9]{2}:[0-9]{2}$`) + +// ParseZoneOffset parses a zone string into an offset +func ParseZoneOffset(zone string) (int, error) { + if !zoneOffsetRegexp.MatchString(zone) { + return 0, 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 0, ErrZoneOffsetInvalid + } + offset := d * (int(h)*zoneHour + int(m)*zoneMinute) + return offset, nil +} + +// FormatZoneOffset formats an offset into a string +func FormatZoneOffset(offset int) string { + neg := offset < 0 + s := '+' + if neg { + s = '-' + offset = -offset + } + if offset == 0 { + return "\u00B100:00" + } + h := offset / zoneHour + m := (offset % zoneHour) / zoneMinute + return fmt.Sprintf("%c%02d:%02d", s, h, m) +} diff --git a/zoneoffset_test.go b/zoneoffset_test.go new file mode 100644 index 0000000..2797cae --- /dev/null +++ b/zoneoffset_test.go @@ -0,0 +1,94 @@ +package main + +import ( + "testing" + "time" +) + +func TestParseZoneOffset(t *testing.T) { + offset, err := ParseZoneOffset("+08:00") + if err != nil { + t.Errorf("want error %v, got error %v", nil, err) + } else { + want := 8*60*60 + 0*60 + if offset != want { + t.Errorf("got %d, want %d", offset, want) + } + } + + offset, err = ParseZoneOffset("-01:30") + if err != nil { + t.Errorf("want error %v, got error %v", nil, err) + } else { + want := -(1*60*60 + 30*60) + if offset != want { + t.Errorf("got %d, want %d", offset, want) + } + } + + _, err = ParseZoneOffset("-0030") + if err != ErrZoneOffsetInvalid { + t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err) + } + + _, err = ParseZoneOffset("00:30") + if err != ErrZoneOffsetInvalid { + t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err) + } + + _, err = ParseZoneOffset("+08:60") + if err != ErrZoneOffsetInvalid { + t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err) + } + + _, err = ParseZoneOffset("+08:-6") + if err != ErrZoneOffsetInvalid { + t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err) + } + + offset, err = ParseZoneOffset("+08:00") + if err != nil { + t.Errorf("want error %v, got error %v", nil, err) + } else { + loc := time.FixedZone("UTC "+FormatZoneOffset(offset), offset) + 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) + } + } + + offset, err = ParseZoneOffset("-00:30") + if err != nil { + t.Errorf("want error %v, got error %v", nil, err) + } else { + loc := time.FixedZone("UTC "+FormatZoneOffset(offset), offset) + 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) + } + } +} + +func TestFormatZoneOffset(t *testing.T) { + want, got := "+06:06", FormatZoneOffset(6*60*60+6*60) + if want != got { + t.Fatalf("got offset %v, want offset %v", got, want) + } + + want, got = "-12:15", FormatZoneOffset(-(12*60*60 + 15*60)) + if want != got { + t.Fatalf("got offset %v, want offset %v", got, want) + } + + want, got = "\u00B100:00", FormatZoneOffset(-(0*60*60 + 0*60)) + if want != got { + t.Fatalf("got offset %v, want offset %v", got, want) + } + + want, got = "+00:01", FormatZoneOffset(0*60*60+1*60) + if want != got { + t.Fatalf("got offset %v, want offset %v", got, want) + } +}