From ffec2af382ba06bd59f71a6e290b7167c78997eb Mon Sep 17 00:00:00 2001 From: Arseny Balobanov Date: Sat, 15 Feb 2020 22:39:40 +0300 Subject: [PATCH 1/7] Add test helpers to find free tcp ports and wait for ports to become available. --- tools/testtool/freeport.go | 57 +++++++++++++++++++++++++++++++++ tools/testtool/freeport_test.go | 27 ++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 tools/testtool/freeport.go create mode 100644 tools/testtool/freeport_test.go diff --git a/tools/testtool/freeport.go b/tools/testtool/freeport.go new file mode 100644 index 0000000..8f07e38 --- /dev/null +++ b/tools/testtool/freeport.go @@ -0,0 +1,57 @@ +package testtool + +import ( + "context" + "fmt" + "net" + "os" + "strconv" + "time" +) + +// GetFreePort returns free local tcp port. +func GetFreePort() (string, error) { + addr, err := net.ResolveTCPAddr("tcp", "localhost:0") + if err != nil { + return "", err + } + + l, err := net.ListenTCP("tcp", addr) + if err != nil { + return "", err + } + defer func() { _ = l.Close() }() + + p := l.Addr().(*net.TCPAddr).Port + + return strconv.Itoa(p), nil +} + +// WaitForPort tries to connect to given local port with constant backoff. +// +// Can be canceled via ctx. +func WaitForPort(ctx context.Context, port string) { + t := time.NewTicker(time.Millisecond * 100) + defer t.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if err := portIsReady(port); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "waiting for port: %s\n", err) + break + } + return + } + } +} + +func portIsReady(port string) error { + conn, err := net.Dial("tcp", net.JoinHostPort("localhost", port)) + if err != nil { + return err + } + return conn.Close() +} diff --git a/tools/testtool/freeport_test.go b/tools/testtool/freeport_test.go new file mode 100644 index 0000000..2cd4177 --- /dev/null +++ b/tools/testtool/freeport_test.go @@ -0,0 +1,27 @@ +package testtool + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestGetFreePort(t *testing.T) { + p, err := GetFreePort() + require.NoError(t, err) + require.NotEmpty(t, p) +} + +func TestWaitForPort(t *testing.T) { + p, err := GetFreePort() + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + WaitForPort(ctx, p) + + require.Error(t, ctx.Err()) +} From a35c0bb295a444589f8dc36623ded30ffea18c39 Mon Sep 17 00:00:00 2001 From: Arseny Balobanov Date: Sat, 15 Feb 2020 22:41:14 +0300 Subject: [PATCH 2/7] Adding http-digital-clock. --- digitalclock/README.md | 114 ++++++++++++ digitalclock/assets/150405.png | Bin 0 -> 196 bytes digitalclock/assets/150405_15.png | Bin 0 -> 1116 bytes digitalclock/main.go | 7 + digitalclock/main_test.go | 249 ++++++++++++++++++++++++++ digitalclock/symbols.go | 152 ++++++++++++++++ digitalclock/testdata/00:51:41_20.png | Bin 0 -> 1469 bytes digitalclock/testdata/02:06:08_11.png | Bin 0 -> 763 bytes digitalclock/testdata/02:07:50_29.png | Bin 0 -> 2739 bytes digitalclock/testdata/02:40:33_26.png | Bin 0 -> 2346 bytes digitalclock/testdata/03:05:12_9.png | Bin 0 -> 628 bytes digitalclock/testdata/03:55:20_19.png | Bin 0 -> 1498 bytes digitalclock/testdata/03:57:01_9.png | Bin 0 -> 615 bytes digitalclock/testdata/04:16:43_21.png | Bin 0 -> 1689 bytes digitalclock/testdata/04:49:37_14.png | Bin 0 -> 978 bytes digitalclock/testdata/05:01:26_14.png | Bin 0 -> 995 bytes digitalclock/testdata/06:12:20_21.png | Bin 0 -> 1694 bytes digitalclock/testdata/07:36:15_2.png | Bin 0 -> 250 bytes digitalclock/testdata/09:00:51_21.png | Bin 0 -> 1680 bytes digitalclock/testdata/09:17:08_23.png | Bin 0 -> 1907 bytes digitalclock/testdata/11:14:07_30.png | Bin 0 -> 2859 bytes digitalclock/testdata/13:10:10_13.png | Bin 0 -> 896 bytes digitalclock/testdata/13:11:11_27.png | Bin 0 -> 2418 bytes digitalclock/testdata/14:58:09_30.png | Bin 0 -> 2907 bytes digitalclock/testdata/15:25:05_7.png | Bin 0 -> 507 bytes digitalclock/testdata/15:37:28_14.png | Bin 0 -> 996 bytes digitalclock/testdata/18:15:02_7.png | Bin 0 -> 507 bytes digitalclock/testdata/19:03:33_7.png | Bin 0 -> 506 bytes digitalclock/testdata/20:17:35_27.png | Bin 0 -> 2479 bytes digitalclock/testdata/20:21:38_24.png | Bin 0 -> 2069 bytes digitalclock/testdata/21:21:21_14.png | Bin 0 -> 959 bytes digitalclock/testdata/22:14:54_27.png | Bin 0 -> 2484 bytes digitalclock/testdata/22:16:10_20.png | Bin 0 -> 1482 bytes digitalclock/testdata/22:48:18_10.png | Bin 0 -> 711 bytes digitalclock/testdata/22:55:07_23.png | Bin 0 -> 1939 bytes digitalclock/testdata/23:43:50_25.png | Bin 0 -> 2236 bytes 36 files changed, 522 insertions(+) create mode 100644 digitalclock/README.md create mode 100644 digitalclock/assets/150405.png create mode 100644 digitalclock/assets/150405_15.png create mode 100644 digitalclock/main.go create mode 100644 digitalclock/main_test.go create mode 100644 digitalclock/symbols.go create mode 100644 digitalclock/testdata/00:51:41_20.png create mode 100644 digitalclock/testdata/02:06:08_11.png create mode 100644 digitalclock/testdata/02:07:50_29.png create mode 100644 digitalclock/testdata/02:40:33_26.png create mode 100644 digitalclock/testdata/03:05:12_9.png create mode 100644 digitalclock/testdata/03:55:20_19.png create mode 100644 digitalclock/testdata/03:57:01_9.png create mode 100644 digitalclock/testdata/04:16:43_21.png create mode 100644 digitalclock/testdata/04:49:37_14.png create mode 100644 digitalclock/testdata/05:01:26_14.png create mode 100644 digitalclock/testdata/06:12:20_21.png create mode 100644 digitalclock/testdata/07:36:15_2.png create mode 100644 digitalclock/testdata/09:00:51_21.png create mode 100644 digitalclock/testdata/09:17:08_23.png create mode 100644 digitalclock/testdata/11:14:07_30.png create mode 100644 digitalclock/testdata/13:10:10_13.png create mode 100644 digitalclock/testdata/13:11:11_27.png create mode 100644 digitalclock/testdata/14:58:09_30.png create mode 100644 digitalclock/testdata/15:25:05_7.png create mode 100644 digitalclock/testdata/15:37:28_14.png create mode 100644 digitalclock/testdata/18:15:02_7.png create mode 100644 digitalclock/testdata/19:03:33_7.png create mode 100644 digitalclock/testdata/20:17:35_27.png create mode 100644 digitalclock/testdata/20:21:38_24.png create mode 100644 digitalclock/testdata/21:21:21_14.png create mode 100644 digitalclock/testdata/22:14:54_27.png create mode 100644 digitalclock/testdata/22:16:10_20.png create mode 100644 digitalclock/testdata/22:48:18_10.png create mode 100644 digitalclock/testdata/22:55:07_23.png create mode 100644 digitalclock/testdata/23:43:50_25.png diff --git a/digitalclock/README.md b/digitalclock/README.md new file mode 100644 index 0000000..4528ad2 --- /dev/null +++ b/digitalclock/README.md @@ -0,0 +1,114 @@ +## digitalclock + +В этой задаче нужно написать http server, +который по запросу `/?time=hh:mm:ss` вернет PNG с данным временем. + +Сервер должен слушать порт, переданный через аргумент `--port`. + +### Примеры + +Запуск: +``` +$ digitalclock -port 6029 +``` + +После запуска можно открыть в браузере `http://localhost:6029/?time=15:04:05` и получить png наподобие этой: + +![Image description](assets/150405.png) + +Если параметр `time` не задан (или равен ""), сервен должен вернуть текущее время. +В этом случае при обновлении страницы картинка должна меняться. + +В запросе также нужно поддержать параметр 1 <=`k` <= 30, +который отвечает за увеличение картинки в `k` раз: + +http://localhost:6029/?time=15:04:05?k=15 + +![Image description](assets/150405_15.png) + +На невалидных `time` и `k` сервер должен возврщать произвольное сообщение об ошибке и http status 400 (Bad Request): + +``` +$ curl -i "http://localhost:6029/?k=100" +HTTP/1.1 400 Bad Request +Content-Type: text/plain; charset=utf-8 +X-Content-Type-Options: nosniff +Date: Fri, 14 Feb 2020 02:02:41 GMT +Content-Length: 10 + +invalid k +``` + +В случае успеха приложение должно возвращать http status 200 (OK), +а также проставлять http header `Content-Type: image/png`: + +``` +$ curl -i "http://localhost:6029/" +HTTP/1.1 200 OK +Content-Type: image/png +Date: Fri, 14 Feb 2020 02:08:19 GMT +Content-Length: 193 +... +``` + +### Формат + +`hh:mm:ss` + +В [./symbols.go](./symbols.go) определены строковые константы, кодирующие цифры и двоеточие. +Один символ константы (отличный от '\n') - 1 пиксель. +'.' должны кодироваться в белый цвет, '1' - в бирюзовый (переменная **Cyan** в [./symbols.go](./symbols.go)). +Все константы имеют одинаковую высоту `h`, все цифры имеют одинаковую ширину `w`. + +`k` отличный от 1 (default) превращает каждый пиксель `p` картинки в блок `k x k` пикселей `p`. +Высота результирующей картинки - `h * k`, ширина - `(6 * w + 2 * w_colon) * k`, +где `w_colon` ширина константы, соответствующей ':'. + +### Проверка решения + +Для запуска тестов нужно выполнить следующую команду: + +``` +go test -v ./digitalclock/... +``` + +### Запуск программы + +``` +go run -v ./digitalclock/... +``` + +### Компиляция + +``` +go install ./digitalclock/... +``` + +После выполнения в `$GOPATH/bin` появится исполняемый файл с именем `digitalclock`. + +### Walkthrough + +1. парсинг аргументов командной строки https://gobyexample.com/command-line-flags +2. http server: https://p.go.manytask.org/00-intro/lecture.slide#23 +3. url params: https://golang.org/pkg/net/url/#URL.Query +3. форматирование времени: https://gobyexample.com/time-formatting-parsing +4. работа с картинками: https://golang.org/pkg/image/ + +Пример, создания простой png размера 5x5: +```go +package main + +import ( + "image" + "image/color" + "image/png" + "os" +) + +func main() { + img := image.NewRGBA(image.Rect(0, 0, 5, 5)) + img.Set(2, 2, color.RGBA{255, 0, 0, 255}) + f, _ := os.Create("/tmp/img.png") + png.Encode(f, img) +} +``` diff --git a/digitalclock/assets/150405.png b/digitalclock/assets/150405.png new file mode 100644 index 0000000000000000000000000000000000000000..e9c67b2365cab80669f55488aef9d98a00aa833e GIT binary patch literal 196 zcmeAS@N?(olHy`uVBq!ia0vp^7C_9y!2~3C@W1>7q`Ey_978JRB>nvV-~Mm_gHFbu zE0fhFB_z^j&pv$f1cOrFO1WU(WtK}`xeBK%T})E# zKpiKGg;lFK+ch@rJkp}4w|5DXNj0ani1370F{@r2eIAhFJn?V4w)o|c_Zv=p-4P^V usH&vr&=a?28ShMiR2i*M4Fv{<|NkFO@nd}*d*LY1Sqz@8elF{r5}E)_9Y^*6 literal 0 HcmV?d00001 diff --git a/digitalclock/assets/150405_15.png b/digitalclock/assets/150405_15.png new file mode 100644 index 0000000000000000000000000000000000000000..3484f025d5eadccddaa1d0294eb31c6855c42ea2 GIT binary patch literal 1116 zcmeAS@N?(olHy`uVBq!ia0y~yVDz@XG3%iz$mftjI0pp1b*@DLw^g3}E~1{WNPEW-E;b|0T(KDVZ?@bby;XZI}r zJ@0d0jdZy6y9G1L)7jUT{h3$(66V?}Y#Rpk#$-~Bd|E527^ zH08#@^C$kZ-^JreqRa<|1jA{YXM0{gDejr=*Cu4mk^lS{fzTsl@R5yG40f*!pT81} zE9=`>#Zoa_wp_rg?9RdI|C;kik1SN{WLC2`q@FVj>u1zopr0D9C%UjP6A literal 0 HcmV?d00001 diff --git a/digitalclock/main.go b/digitalclock/main.go new file mode 100644 index 0000000..cab7d3a --- /dev/null +++ b/digitalclock/main.go @@ -0,0 +1,7 @@ +// +build !solution + +package main + +func main() { + +} diff --git a/digitalclock/main_test.go b/digitalclock/main_test.go new file mode 100644 index 0000000..20e8bff --- /dev/null +++ b/digitalclock/main_test.go @@ -0,0 +1,249 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "image" + "image/png" + "io/ioutil" + "net/http" + "net/url" + "os" + "os/exec" + "path" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "gitlab.com/slon/shad-go/tools/testtool" +) + +const importPath = "gitlab.com/slon/shad-go/digitalclock" + +var binCache testtool.BinCache + +func TestMain(m *testing.M) { + os.Exit(func() int { + var teardown testtool.CloseFunc + binCache, teardown = testtool.NewBinCache() + defer teardown() + + return m.Run() + }()) +} + +func startServer(t *testing.T) (port string, stop func()) { + binary, err := binCache.GetBinary(importPath) + require.NoError(t, err) + + port, err = testtool.GetFreePort() + require.NoError(t, err, "unable to get free port") + + cmd := exec.Command(binary, "-port", port) + cmd.Stdout = nil + cmd.Stderr = os.Stderr + + require.NoError(t, cmd.Start()) + + done := make(chan error) + go func() { + done <- cmd.Wait() + }() + + stop = func() { + _ = cmd.Process.Kill() + <-done + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) + defer cancel() + + testtool.WaitForPort(ctx, port) + if ctx.Err() != nil { + err = ctx.Err() + stop() + } + + require.NoError(t, err) + return +} + +func readImage(fname string) (image.Image, error) { + f, err := os.Open(fname) + if err != nil { + return nil, fmt.Errorf("unable to open %s: %w", fname, err) + } + defer func() { _ = f.Close() }() + + img, _, err := image.Decode(f) + if err != nil { + return nil, fmt.Errorf("unable to decode %s: %w", fname, err) + } + + return img, nil +} + +func abs(a, b uint32) int64 { + if a > b { + return int64(a - b) + } + return int64(b - a) +} + +func calcImgDiff(i1, i2 image.Image) float64 { + w, h := i1.Bounds().Dx(), i1.Bounds().Dy() + + var sum int64 + for x := 0; x < w; x++ { + for y := 0; y < h; y++ { + r1, g1, b1, _ := i1.At(x, y).RGBA() + r2, g2, b2, _ := i2.At(x, y).RGBA() + sum += abs(r1, r2) + sum += abs(g1, g2) + sum += abs(b1, b2) + } + } + + return float64(sum) / (float64(w*h) * 0xffff * 3) * 100.0 +} + +func queryImage(t *testing.T, c *http.Client, url string) image.Image { + t.Logf("querying: %s", url) + + resp, err := c.Get(url) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, "image/png", resp.Header.Get("Content-Type")) + + img, err := png.Decode(resp.Body) + require.Nil(t, err) + + return img +} + +func TestDigitalClock_valid(t *testing.T) { + port, stop := startServer(t) + defer stop() + + files, err := ioutil.ReadDir("./testdata") + require.Nil(t, err) + + c := &http.Client{Timeout: time.Second * 10} + + for _, f := range files { + t.Run(f.Name(), func(t *testing.T) { + parts := strings.SplitN(strings.TrimSuffix(f.Name(), ".png"), "_", 2) + + v := url.Values{} + v.Add("time", parts[0]) + v.Add("k", parts[1]) + + u := fmt.Sprintf("http://localhost:%s/?%s", port, v.Encode()) + img := queryImage(t, c, u) + + expected, err := readImage(path.Join("testdata", f.Name())) + require.NoError(t, err) + + w, h := img.Bounds().Dx(), img.Bounds().Dy() + ew, eh := expected.Bounds().Dx(), expected.Bounds().Dy() + if w != ew || h != eh { + t.Errorf("expected size %d x %d got %d x %d", ew, eh, w, h) + } + + diff := calcImgDiff(img, expected) + t.Logf("image diff: %.2f %%", diff) + require.True(t, diff < 1.0, + fmt.Sprintf("%s images are too different (%.2f %%)", f.Name(), diff)) + }) + } +} + +func TestDigitalClock_invalid(t *testing.T) { + port, stop := startServer(t) + defer stop() + + c := &http.Client{Timeout: time.Second * 10} + + type TestCase struct { + Time string `json:"time"` + K string `json:"k"` + } + + testName := func(tc *TestCase) string { + data, err := json.Marshal(tc) + require.Nil(t, err) + return string(data) + } + + for _, tc := range []*TestCase{ + {K: "0"}, + {K: "31"}, + {K: "2.5"}, + {K: "f"}, + {Time: "15:04"}, + {Time: "15:04:0"}, + {Time: "3:04:05"}, + {Time: "15:4:05"}, + {Time: "24:00:00"}, + {Time: "00:00:60"}, + {Time: "f"}, + } { + t.Run(testName(tc), func(t *testing.T) { + v := url.Values{} + if tc.Time != "" { + v.Add("time", tc.Time) + } + if tc.K != "" { + v.Add("k", tc.K) + } + + u := fmt.Sprintf("http://localhost:%s/?%s", port, v.Encode()) + t.Logf("querying: %s", u) + + resp, err := c.Get(u) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + } +} + +func TestDigitalClock_now(t *testing.T) { + port, stop := startServer(t) + defer stop() + + c := &http.Client{Timeout: time.Second * 10} + + for _, k := range []int{1, 10, 29} { + k := k + t.Run(fmt.Sprintf("k=%d", k), func(t *testing.T) { + v := url.Values{"k": []string{strconv.Itoa(k)}} + u := fmt.Sprintf("http://localhost:%s/?%s", port, v.Encode()) + img := queryImage(t, c, u) + + w, h := img.Bounds().Dx(), img.Bounds().Dy() + ew, eh := getExpectedWidth(k), getExpectedHeight(k) + if w != ew || h != eh { + t.Errorf("expected size %d x %d got %d x %d", ew, eh, w, h) + } + }) + } +} + +func getSymbolWidth(s string) int { + return len(strings.SplitN(s, "\n", 2)[0]) +} + +func getExpectedWidth(k int) int { + return (getSymbolWidth(Zero)*6 + getSymbolWidth(Colon)*2) * k +} + +func getExpectedHeight(k int) int { + return len(strings.Split(Zero, "\n")) * k +} diff --git a/digitalclock/symbols.go b/digitalclock/symbols.go new file mode 100644 index 0000000..8578102 --- /dev/null +++ b/digitalclock/symbols.go @@ -0,0 +1,152 @@ +// +build !change + +package main + +import "image/color" + +var Cyan = color.RGBA{R: 100, G: 200, B: 200, A: 0xff} + +const ( + Zero = `........ +.111111. +.111111. +.11..11. +.11..11. +.11..11. +.11..11. +.11..11. +.11..11. +.111111. +.111111. +........` + + One = `........ +.....11. +.....11. +.....11. +.....11. +.....11. +.....11. +.....11. +.....11. +.....11. +.....11. +........` + + Two = `........ +.111111. +.111111. +.....11. +.....11. +.111111. +.111111. +.11..... +.11..... +.111111. +.111111. +........` + + Three = `........ +.111111. +.111111. +.....11. +.....11. +.111111. +.111111. +.....11. +.....11. +.111111. +.111111. +........` + + Four = `........ +.11..11. +.11..11. +.11..11. +.11..11. +.111111. +.111111. +.....11. +.....11. +.....11. +.....11. +........` + + Five = `........ +.111111. +.111111. +.11..... +.11..... +.111111. +.111111. +.....11. +.....11. +.111111. +.111111. +........` + + Six = `........ +.111111. +.111111. +.11..... +.11..... +.111111. +.111111. +.11..11. +.11..11. +.111111. +.111111. +........` + + Seven = `........ +.111111. +.111111. +.....11. +.....11. +.....11. +.....11. +.....11. +.....11. +.....11. +.....11. +........` + + Eight = `........ +.111111. +.111111. +.11..11. +.11..11. +.111111. +.111111. +.11..11. +.11..11. +.111111. +.111111. +........` + + Nine = `........ +.111111. +.111111. +.11..11. +.11..11. +.111111. +.111111. +.....11. +.....11. +.....11. +.....11. +........` + + Colon = `.... +.... +.... +.... +.11. +.11. +.... +.11. +.11. +.... +.... +....` +) diff --git a/digitalclock/testdata/00:51:41_20.png b/digitalclock/testdata/00:51:41_20.png new file mode 100644 index 0000000000000000000000000000000000000000..67ecb564d6c5c3b550cd419001a1fd0c2149b6fb GIT binary patch literal 1469 zcmeAS@N?(olHy`uVBq!ia0y~yU`YV7KX5Ps$pfyf>lheVTRdGHLn`LHxp}Zt+EKva z;?|jStdv|$1xrIdJ&hOdc{BIc)jj3nv5VT<{AJHS|IEz5(9qw1|1ywfs1ax4WMELB z32}V?pC22)|DWCBKR3H~&g;7MhNb(ujLkfNDN2xLgyk}J zhO{}^zWodz!uNz9n3^*IXq^(xh$0_;hBFm!Y>VFQytek-T%*$cA4K<^c

I^K0I2jrX zm>d+abHmr%NmhOQdd-^3$9vDHh!+#(&<#cJcOSc6bucGwM%L?hKQ8ka|5P={88pa_ z*Z!WN)Sx!;S~$^M2PITz@9Nt+*oh_VM`!&64|$@?5iEH{LlZdQP)6!N5dvIALq;`yc=K nV$E5uLeb4l!Z&?%xcx9if^0y)Iy4E4QiCA~4sES&=q~`roXxX4wukyluc^&BdhbwH`rL$bEv4sfwQRl- z9dVZPSSK)6%V&xwf>LS-2bfYBnpDhgc7_Mua>eiD9`b&d*zL~n;PH;r3_r}`S{Q+T zo1ie1se-q140b1y&)ro9=CLXDk6+(Oej~?d*9wWZAsS)OWCn@1cZOyC3=Hv)^q3#q zt{4Vo2`~dPJdnQ&jHbr^4>O9tbL4N>&hY1K?6z}}m*+iQvzPVX2PGb0x@=%k9Hj%QTTggw+vcCY{`zZvpiYOA`>!$sX@(eI0Y)HI z#3BeJ72F3GC;j)E`PDMV)Bh`+w*6lFl_US=_V>n>_bt*tS(GkRmmKGC774_>3@mQ zZ?=NHC(EZ*H){LZaZk2f{`|)FVyoZJS~-9LK0&zyNHSP94Mxu9wQ>v(CmSz7&$qs*&9BC9M1CKSdBBoy63k~Pkkf3|FhGe zPvkel_!$cL&)3G=GBGso5NCLIj(ac`H7F%tdbBM=&GP5_*2^{W1Ir&^>YueIe{%Zl zoKO20?bYUAK6(G%$<4lImG{*)=RYs`c6RXl2*rDYnF9>^1Et`E?)2uiB`Db(ik_U# z{H$2NM*jOD%X3WOoAUn*fh07T!x+VF&)>iP8f6P=3mjOwXK+>jDDH%E{;@MK{QrMt XL1et6dfq3Xs~J39{an^LB{Ts5jQxp_ literal 0 HcmV?d00001 diff --git a/digitalclock/testdata/03:05:12_9.png b/digitalclock/testdata/03:05:12_9.png new file mode 100644 index 0000000000000000000000000000000000000000..ac91621e4cbe3993f1489e1cb5607cc10c86dd8d GIT binary patch literal 628 zcmeAS@N?(olHy`uVBq!ia0y~yVEh4O=Ws9qNzPN|Cm9%+tUX;ELn`LHxqC2|$xy)I zqRJw5Ew9j(9jna3^=>vZAFJ74-m5h2=&w(yCet5V*fG5LRrQsb;X{lQzkvD|hC&Rg z-16t`?{CdMdb<6IS5d6)uFwADnpZVgEM)HxCWG3snKwB3+WmV* z#qQ^SMQ5f({m<#y_%)L~|1MrrtN$DjS#M;Pl6WJX{oJul&?vQ0)wyNjS2l4de;yi2t0S%q)j}Kfq^KjzK=*(kwr!~&n zrazvy`D1Uobd?EGDoPpv0|K`trkGK*|Gk~dy!PC{xWt~$(69D>w1m^$% literal 0 HcmV?d00001 diff --git a/digitalclock/testdata/03:55:20_19.png b/digitalclock/testdata/03:55:20_19.png new file mode 100644 index 0000000000000000000000000000000000000000..947a57727adb1154e4435c856f6e27378a1f5b94 GIT binary patch literal 1498 zcmeAS@N?(olHy`uVBq!ia0y~yV9@}wpKve%$*cKI{Rz5g5SW{W&#?*F~7o{@oJ%D**oKo z6NG^R4t^CtQA=tF_8qVPxpQ6q+?M)thHCZ~t;C+Kf6VvhYLoGE**TW1)mtB&scb*~ z`o%G328I)y{0s~V>NX4v4RbyK?Jqhslmum0v;Sx+?m6lIC+7MS^>gO`qvz(gmoC|G z`1VHT*C&jv-^l!z=dN4-uU(p^zPz)sis6BD&ARMQ!smejF8IT8j^zR7JlY28@mGuy zZ;E4AyEn*ZwQqkcll+y#65{6>XL@Ons1OPDU4$*e2i|jrVf_p_M`kkHJfLktDqkbl za1>i@hynR$4O{Qi?YcuwlOk0<{1�Kcxv&tQ;_{>H!OJeq zS!(6ZWA+@MTf=J%LZ5aDoVSn`C!}ucr;jI2&GahXZ@OLjs_mMnO0ydiq;Uiw)=)(i zyz^Y$=dC?|#(&XshF?3=zs(nY>LPopS5Cq21>>U@GN^a|B_6*qWhhWEuWGPZ$lf80 gjrz^Y!0`Wnw5-RYgE9HOzy!qL>FVdQ&MBb@06%*1@Bjb+ literal 0 HcmV?d00001 diff --git a/digitalclock/testdata/04:16:43_21.png b/digitalclock/testdata/04:16:43_21.png new file mode 100644 index 0000000000000000000000000000000000000000..d99c377cef3a1ecb8a4db2df6e7569ada1f5b677 GIT binary patch literal 1689 zcmeAS@N?(olHy`uVBq!ia0y~yV3`4A|KVT)l3QdtcQ7!pC3w0xhE&XXbMs>E9SaeM zi+3GtH!6iL?TVNhbU5LW&7BW(>YM$=e1A4w|Gn=&BLl;QkJ(i~-3)fhPk>}niwpyU zL(c?e28Is7BA|ew6CVSEg4+q8$|D?>3=9ntitG#w0+cDi700obxNMNyXHD z+gn}sW47PD&-)64=adHj+3Rol^QQl|r`6@kl-or+Ur^2DVqg9bQ`6TTy!w7GdpP@p z`)0p;na>q|%AEYK{$4LV;sePi&t}OnoY?VQF6M7!c0uU_nR8YO2M59pc4hBvZJ7@A zTmM{BeQlOpBcuBJpOJN+1M}#YN+F?g{R@M`vCY@Ma4^`Pi)2^XF_E@e11TyXnJ(rs z^Mo&JOl=R%sDA%a@Yor<-zRtX8J~SunSOW5&F^)eJgYy=`+jfn9qQ};X+?^9+n zFf?kO-m9)+u{Z&WlLCSJJ2cN(`90p!Su`@riqHRl@4IXaESNtqn$xU&#vVsA?gJ}< a|Nobp2mWN+dm{ko1_n=8KbLh*2~7YKjFRpE literal 0 HcmV?d00001 diff --git a/digitalclock/testdata/04:49:37_14.png b/digitalclock/testdata/04:49:37_14.png new file mode 100644 index 0000000000000000000000000000000000000000..4f91e62bfaafe8f458b538bf52c2270a963a31cc GIT binary patch literal 978 zcmeAS@N?(olHy`uVBq!ia0y~yU={$fS8y-^$^WnB>jMR5db&7erH$cwa+Ju*B!E~QhT1U=Kb}{ zyhNEr*f!!-wB){fegC)JpPB0)OZ@YBVEHG0*M9A9FSu5}+5PoTo3t83gTf33hNc82 z297391_chH1U8(NV>q_sk@mIKH3y~L3i}=in-dHmtWHjxZOfn>`*CUEyYu_{Q*Ga$ zm;3nn4ru`oGq)x88-u~I>ig;a45y1Ja?yd}wVy&iTrWH$_fR|hOyyCzo8LFCc^+}@ zV#Mk<*7pkz6P+etP9q^1Vbzj2`}Z%Pb7ULhAMbs?*}dWE+M>QcWgo~5DN3C4qk7)+ nHjMQ=dAc};RLpsE_u|^w1`-Yr zw#J}D9etZ40=R4J2?^~vR?e6E8$=@&TU1#sFPLw%> zZ6ZO%wvF-s?|r{*|G89Vd;K$=c@^_&?0~z|fSy#K6(S$)LcY#?YWZl)xdgDu#Doj`%+IFQ~TsV4wDL zZS+l@?2^B=Pg+Pa2X1xFV)lmWnEi!PKaSWn`OhI3Z*VUmM|Df?7lzlfo`!w6Uf3h| zaFY77IjOx5^t9hEj(j%fsPOsXH@{DC5}h)z+CXCJ0-4d0`{w%ozT`Iy%xnMD*B`3B z@7{0FvtRXHl6>v{$ZHwXo>$HlCOX{V;YEB@Vb`+%{AU?kW`+ad;qWkdd3^BoT?|Ffk#R=(kmd;j|%GXsOe@%qa^is6H#B0B?vfbtU{ zJE=tmDA6+kDBdAh1QalI;$vV?a67@sz;J}al7XQ?f+EFdzCNA5|NB4Z$scB>uRVDA z?(Zo#tDpO9uD}0wm+{Tg;F{0DZ+02iR=$s;hhu2xO=3-6_mXknr!}S(zxIBA@KSyD z-tq@B>iN?DkIg-4e*XRE|3BqFE72`#Aa35djGe*g-ClP8`fE})vKH;J>?#!#X_Xpq z1XIjq<^#vy?OpCJ@p+A@EtAZXC%K1b)_fLts3vy9e~n44>+i6K=DgEtOq(B})HL;q18wZfdMQT^qY!uU6;u?|r4U zN7n3^SyR`&dZB@s$g!qFQ_j23yeIf|rO*0>_by-7uD;O^l#y=hQn#9<i&5;Qe2;T7#RNlKbGp_?5*^&1?U+DPgg&ebxsLQ0E97Y=>Px# literal 0 HcmV?d00001 diff --git a/digitalclock/testdata/09:00:51_21.png b/digitalclock/testdata/09:00:51_21.png new file mode 100644 index 0000000000000000000000000000000000000000..755fb7836cf8aee4f60adb9470931348644c215f GIT binary patch literal 1680 zcmeAS@N?(olHy`uVBq!ia0y~yV3`4A|KVT)l3QdtcQ7!pg?qX*mj$6cTQ;jeOZdba3w?frP5u9819<$zSfc@9e;DWS;Dz#yRf z1Spc!BE!Jo&@+LVfuTdN2q<9a#K*v(;C6zMf#C>cir4Aa*T4VkJo&@S^tA`CzWqJr zX0829WBa4AYLxs zIX2UNa_w`I&FOMQd#DiJq&lXdr~3WDtMj+Ag|nY~xQrQoqU)&Bhk zY2WtGuX)w*|K*e2@1D&5_T;^F;0VeeMel8GnPi?sW>>X1?|gkO<^J+HHPs9^H5@4xq5HU^f$9~kwiTNo2`+>HIe6951I_8DGGJRwW0fNo&$boFyt I=akR{02CySeEkYdp0Oa_vd zS`2|?<{=3Nh6a;F9tH-7Sq5wj3<|yy%nS?@)Or{h7(9fJ0hKJFrt-Hto`%a6-}_{} z=aA*GSAz52oILmKrtpux+IN#{&z-aT&pH3?X7!q9Z{Fn0-<<4UR(5(`g(2--MO!~p z$@IRf%y-_%B!A`b@3#!^KOlco_`%;d#hc$AmOXub(|OIZva&S)xFg*I5v#~Pv<4=! z4+oRaT~)T3o4iJj@j=?Vhz9-20guJwuNV^=kIz}{4ovU+pILu%-9yuajvOR7veB00 zuM7xO u#sB#0TdRNtI77s-0Z%x;Wf>U$|L>pRskwmn%u%457(8A5T-G@yGywqOtjY!e literal 0 HcmV?d00001 diff --git a/digitalclock/testdata/11:14:07_30.png b/digitalclock/testdata/11:14:07_30.png new file mode 100644 index 0000000000000000000000000000000000000000..3646dd5e94b4d37eef667a04424cdd7cf9f69182 GIT binary patch literal 2859 zcmeAS@N?(olHy`uVBq!ia0y~yV4J|ez?i|o1QfZiuAjre!1c+~#WAE}&YN4Od*?Vv zxCP!;THdu{@v9Cktv!+_@10rn_rZRj`N9X)LmEm#k5|sS57f!f&?bMG8O(@hu?Eo` zf^R^Sg3AVwaD%`>J`mSwXi4_HWc;)C_`S=*ao=LHtC#}M8Gq2a-qBu|&FSCnQ&aQsbHGb*8otQ~UQWSKrfWJ+IfgXkPvE zHd&BV3r87<5>UFq2&5c364*gp#ipSsDFe)zJ8mVvf61v2@-SnA{-Jt-20Qa%kU>$S z{>cnEU?ly&2l6TdL;Z>2mm@8gu`?8GeI9jLm?5F(zXC&T-Y`g?s9EiVp)EthYTIw~ z=6%=%^5}!LmF(*akH7ycd-tPdH9OUOE1SfngoE@?_}wDF4J5 zRMs-o6dWFktpU`+W=J<|Nbt&&-<9(&OM_~^2L54K+MEZ~82^>dTD{@Ty>3{$L literal 0 HcmV?d00001 diff --git a/digitalclock/testdata/13:10:10_13.png b/digitalclock/testdata/13:10:10_13.png new file mode 100644 index 0000000000000000000000000000000000000000..959a6d16dae24f90dbec33995626a96d755cdcb6 GIT binary patch literal 896 zcmeAS@N?(olHy`uVBq!ia0y~yV7dWh&*5MKlBb?~GcqtRyL-AghE&XXbN6DWvY`OO z#odQ3HI>{=1vOXgEMGvR9SxvCcZ zwR3Zy*E7a#=9W26=jgCZw>=cS>(~nSgzWj7Bg-1E^W9+8CpvlIj%(tQ=(G2=_rLNm p9BBWfL?|U=ST*xMFdO~<|4+--Da-%0I51~1c)I$ztaD0e0sw}3?^gf- literal 0 HcmV?d00001 diff --git a/digitalclock/testdata/13:11:11_27.png b/digitalclock/testdata/13:11:11_27.png new file mode 100644 index 0000000000000000000000000000000000000000..2d4dc4716aebc62033a4ef57094ba4ba56bcea65 GIT binary patch literal 2418 zcmeAS@N?(olHy`uVBq!ia0y~yV12>B!05uk1QfY!>gmJ4z-j5};uumf=gqCg&f5+m zt`~g+{DUX13UUpedg;I+HnGp^*A{D4NStgsZU64~Kajo!&)L2V>`cpX_1e~_Vxea_}~1=_h07~MZWy!-+S8sfo042 z2eHc@G@f7BEe&*hgT_NPAf@27gAqt|2;~7uhD9x-QdA0!70Fc$dyZ_q_N8Te`8!)% zU?4wu88fKT3*i)f{0l=ve<{%Q581CrT^45ep}$X)q5tWlp_Oo~ma!l3o>T4b4~*G{ z`$dI=Fze!p3Sh2gu)79KM=T6>E%{srl6MZV4BPjT@q-X3`P{n)Dx9`o|IfQuli~d# zbJhnNhgyah#HBy}B=kV_+}`Et2d)p{bTl-preDSPzkdOhmkj$J4zY6cmpB8%|Nq$u VCQH8lDJ}qc!PC{xWt~$(695x6?_B@@ literal 0 HcmV?d00001 diff --git a/digitalclock/testdata/14:58:09_30.png b/digitalclock/testdata/14:58:09_30.png new file mode 100644 index 0000000000000000000000000000000000000000..d1c4247b24aea7aa154271de5ee340e9cc4fb013 GIT binary patch literal 2907 zcmeAS@N?(olHy`uVBq!ia0y~yV4J|ez?i|o1QfZiuAjrez^&xz;uumf=gqByYv&j+ zxL#CQq#kY(8aQ#a(rk~&vyZfY{;0B8-lU%5u~(&iMur6sufJjhF%HBx$%5z> zjxrD>pmYNy?9h?G4&o{fEy?4r82_xkK4-Oh%}vW=uQ=nMS>8Wf{A}`@ig`Pa_uc1x zKC`^c=KP(*<;~?~fATEO@A+K&{3fq8$fF#BZ$OlS%LZm3)gW+?55#q18I>GFfiNNY zD=_6gl1cu`@*^J@lj|+R{Ttjr-+eII;{5L7!^1Ah`mQoF?6{qM?yB&%aPsl; z3zhqh_nogi{+Va~5$lS6+5PA5A0K{EJ#6b0aAEwetZ!r0A>}>KVr&`Z&RUCS@P58UF)t&gPJ=H_IHL|^Jl_+V0-re|3!g87jw!AoI&37boFyt I=akR{0J47!DF6Tf literal 0 HcmV?d00001 diff --git a/digitalclock/testdata/15:25:05_7.png b/digitalclock/testdata/15:25:05_7.png new file mode 100644 index 0000000000000000000000000000000000000000..02f74414c887ef91a6a7db26d8feec52a0f7eb29 GIT binary patch literal 507 zcmeAS@N?(olHy`uVBq!ia0y~yVC(?0LpYd#r1&cHi9mrvo-U3d6?5L)J?MMbK*Ax> z;qFRdLs8*Utzx#X7o277)$fz3vd)I-e?E6OZ6OYNV-Z$Fcv35U;L31aj=I2`d+-po9E3n-=Ew^+A7Gu|a+Hs(# zeAD_Gy?;E!iyZ*1@ z9l5Wp-%lJt_BmW7C>YQ=Z~m4x#-4j0|J&|Y;`6ieWnU%FZFk=t^H&p#P1y7U1NgJ$ nv538F4ffcazMqkS;s5^^9D!$Ty~B?KBbC9^)z4*}Q$iB}Itj%r literal 0 HcmV?d00001 diff --git a/digitalclock/testdata/15:37:28_14.png b/digitalclock/testdata/15:37:28_14.png new file mode 100644 index 0000000000000000000000000000000000000000..0086138e6a9306b98c15185783ac133904fff658 GIT binary patch literal 996 zcmeAS@N?(olHy`uVBq!ia0y~yU={$fS8y-^$^WnB>jMQ=d%8G=RLpsE_u$IM1|kj@ zXCAcOxhQ06SIpGqP3O-#IbAz%$=cE`$0eRybLabCc7}%ef1_;~7!F+KRAXpRn8Co% zl)%Km(ZtE1z(G(zW;MIS`_kCM^`C3!9j-4vHfQ&Y!g;$leBU_ddHE&~slLa6k%8qP z3xfcQFoT1D4+DcE%>^vN_z!rz{~Tk>lsH{>@4D=-9P)+p?w`CjKmS{JKR4A>yzi3hY_|6%4dIE`oVMm&(}|}Wi0;B zhgHmf{z@>R`kmmPBi!T#G%(aY>@EAL_2c!fu>KDdC5zA9n7H?I%I-UhCCg?OiyNOS ze*1qCnNB1zRiT9c@mGxdu7lmQde8Y{U|eo2pO-Ve+T^fadGU1Fw?*^je?COlxDBB%#t*#C)2K~Wl->XASWgQu&X%Q~loCICpq{jmT5 literal 0 HcmV?d00001 diff --git a/digitalclock/testdata/18:15:02_7.png b/digitalclock/testdata/18:15:02_7.png new file mode 100644 index 0000000000000000000000000000000000000000..55ea51a91680b8ab1f4578b37d8695361bda40db GIT binary patch literal 507 zcmeAS@N?(olHy`uVBq!ia0y~yVC(?0LpYd#r1&cHi9mrvo-U3d6?5L)J?MMbfWaZq z;qFRdLs8*Utzx#X0xNz02=6agnXSjw{_mM?>9sKl> z_1#MCqR@3KR(IX+l4rG^`+HXLKGl7O1(F7nWBMLfFl+q0VasqJ{}9U)4&e#PJ`Q~b zjFv!7%)D>2?ee!<*ME9lRr|vJsSp07o}t*bry^PlGTj8nIoi*Ff%twFJK&eOuiy z2c~>}vHtm)`k$KF{O2-{qq!PrT=BHytlx^{_MW%(+L(;#yqSM@IDDA>`v0D1um4{D zbq2R%P+W%KY`(8&aP(d|+nc{ewI6o>sOOrup?=nbIc4yGLnuWLQi#mC--R-}(;0po i?86MW=hh4i|Np0I2KPle$XEg6l)=;0&t;ucLK6V!Y0fhM literal 0 HcmV?d00001 diff --git a/digitalclock/testdata/20:17:35_27.png b/digitalclock/testdata/20:17:35_27.png new file mode 100644 index 0000000000000000000000000000000000000000..67a39b3d6117305f33a11061bba8e37cc8dfe2f2 GIT binary patch literal 2479 zcmeAS@N?(olHy`uVBq!ia0y~yV12>B!05uk1QfY!>gmJ4z**+$;uumf=gqB)op%gG z94@NtP!D~wDrVxUBD+t!4ZPpDmLGdAq9FG$bI#rj>+_$*fjSv%>%;g#^nvRw(m<*~ z;~^W6Qt;Zr2&6iM@_;16qCq9K;_Wx#@0R)YlV>IOTvgtaz(0S%{`P>zc zse5+$P4POpM)~XeHNOcwnEy||Y<@l$>$^{-?>-6d&~%I&V&Q)K6(fV)HM{4p1mi4@ zMc6Vi+HO=fAIMb|j+RltVu{8j_SUs>35~VqfHlZoa6MuRtS$1m7|K8T?mYK@pT>svKkP#+ zd!Qz@!B}-;bz$B7=R2!F*_dJ9gQG*MlKu*8Km7myB-3y9-<4_tATM~j`njxgN@xNA DQQrWE literal 0 HcmV?d00001 diff --git a/digitalclock/testdata/20:21:38_24.png b/digitalclock/testdata/20:21:38_24.png new file mode 100644 index 0000000000000000000000000000000000000000..d2f8e7f309b0dfff8de7cc66ee3c6660b5300e11 GIT binary patch literal 2069 zcmeAS@N?(olHy`uVBq!ia0y~yV0B<%U{v5>0*d^)@2ASZz<$Tm#WAE}&YPPLJMUPD zI6VB`R2}3Rd^KdrDz*LMP16nTP3-$z%Q~Zbr;tp^p1Ae@fjS%Z{*AH)(hMJL8To+} zBaq zAhzE!y#K?|;-0I@3^@n?@)YRm(={a$aIZ{w?F+-_q4Sv@IK4}6u-!R-r||DTl5up( zCIlP@$+;F`{0tw~J(pPR&hUW$&UuFGp9WKQIk2&+Vg4sjCO^<_^DM^pkodd##sB#K zSl>DSyli&5IXIu+e!lx%zxV)@Lqr8IeTzn%PU^eL{AbGXIjh}o+ydp6oU{M7+;}eb z{PmpLXMS%!TYgt8PnvIkn0vs=bes+!xM~KAlm73ITYuFUSl2P!|1hZX$$Oxa{{Nq? V6Y4lUEiM@3Ku=dcmvv4FO#o{4g$e)w literal 0 HcmV?d00001 diff --git a/digitalclock/testdata/21:21:21_14.png b/digitalclock/testdata/21:21:21_14.png new file mode 100644 index 0000000000000000000000000000000000000000..73d9a8e258bffd5a44d28ed7e66e7cb744034fc5 GIT binary patch literal 959 zcmeAS@N?(olHy`uVBq!ia0y~yU={$fS8y-^$^WnB>jMSaJY5_^D(1Yod$DtggNVb$ zyGQ5rtXTZ2qvurbft`n@#Olubtb3V1TQ26+@znd@?HL$kew$Y_Ff`<=&tPC^N?>B( zXyRm0;80^|P#`F{ipwx(=C72edFlcL8hySPcv|jz_e^Q^Y`cVZD(a4 zECKnd@xv42P3}FPK;)k@ymU!1$mz0W|L=Y0=P@!oSWRvO{bvT|#sB)#TB!05uk1QfY!>gmJ4z*+6-;uumf=gqCg&bbaE z4i{B!Tnp!B;(K*!rpF~#yZ>mjt!e%z=j8bR`-cB|)os@++eE&6 zpYZzE`=WY&+rzK(>f7=LqbCM`Fq7!Q-0kx#dJ2Ef%-?)|XI0Db&F$B3Ix{rh&k(nt#@`X0&_!$P#!2#ENYPkk_{RU*?^RS z*I<;|cH95X^F4okeuRG$X!!o&YV-LAvHRXhB!A`TPv>tQa=y9B%uur?x#y}f!w1Eh zV1~HUy@NgmP|`?NwCw>VP?^P0{e$cG9B|pyGXKM}nCt&--@LXz{BQQ9`5V^nZ?FCK zVVC&3h0;T%JSMTA7-W^nD@L&!&tq&4J(f*gBlqy*n}?-;ZQuBF90>lgEav%r@ect* yt?(L}(ec;reDPNvP{CSraERsWSNT9!|NlREX6O=)(D2J3KX|(OxvXlheVCwjU#hE&XXbMs*CZ3h8| zK=lOw=!vV6T-S8%{FBXdc$3AnFZbr&V7w;vBYFF08+)L}A3uIr+ky`{dBAE-@3P}ucyU-VSe>?TTYwxD{H^@{Ex4strt(2*)kYBE|XDy{w}+B4!4c| zZ>}Aa#8ns=8aQZ1#^dWHNia08pUM)5S5QV$Pepr*j`0h&Wt4 z<6ygT(Mm1RrCPqM{|@y0UAMM)n;TPL-2ql{we62B_Aw;fy8n`$p+Vn~X@S!O1`h!h z1|b&C1{Oyq1xEz8N zEKxzb!S;Q;|J~>IrRx8!*OkADE!@9yXH??4U$cr++bNG4P_+KB>`VS7$#8&=vw;mY jiJ*m4%YQ}&hX4PA1;ak>OUN_?CQt@XS3j3^P6T|f;!8M_f#jtYLm-)XNP>Z(!6cD~fx%%GHI?^8`&G_)uKWCh zz0LJowujiOjc(4deR%5b%$##~H>yuQdsBGbow+BU-H`=K=vF^EN+V~`)|1pX-8#?m z>CS7q&-Y9|UinTgQKq^m)&EE2ouqw-=N;HTv$*_^-OgE`@6UXE_S=zen#LayUb8Oa zXZUdPu}yjZhle|_y%S`}dnV2OK~3aqwgCYgqmgMgY=GE)ARmBf@#dJBnD*{L`?vG!8{hcJR!by-G`0^ni=#KyY YuYU>ib(uWD7RX`nboFyt=akR{0MMe}5dZ)H literal 0 HcmV?d00001 diff --git a/digitalclock/testdata/23:43:50_25.png b/digitalclock/testdata/23:43:50_25.png new file mode 100644 index 0000000000000000000000000000000000000000..7db2d1823be1c96f8a91ee92d94ef7ae9ca70ceb GIT binary patch literal 2236 zcmeAS@N?(olHy`uVBq!ia0y~yV69+aVASDY0*ZXI^qIuKz|rjK;uumf=gqByy|)bn z94@K^_^&w;x>70ZsZ7xwR|SRpimCg<8w@76+0=Zfu$ylW)XBiLKa3wl@8Ms_2&7JM z@B&E(w+xV&krOkJoFFI#BpZ4zfP_+7*np&hvI&SINn-C>zpoD$K`geMFw9N;~vGfTNn%Qzj(C)R2 za?P#k>N7j5T5fL$zkXBuf!>_A*DY`Vl<{MWOFX}~>i6cmy>yIzlKk3tm6<{3!;E5J zI-5`2(< zXHaB6ERKH`VasrU>AA#ecLs%LIqWxDqz6%Pf#P17)$A2Ji+iprfAFt4_*$-bo!mpS zZ#Ci7dB5iczs)?q_txaSyUxXd%8*;dx4+NH@~?g;nsaW@CU1HcyC~sETzycyM()8= zNFbjx4D0{E8$TiZM)B>3#TK_KS<`=){&Tcz&y7Dc8ocDyou5DdtXVe?R6kb;=h3C= hB_j=dwq{`X|KIFvY{2uEOVdH^15Z~!mvv4FO#oYywuArx literal 0 HcmV?d00001 From 1957ef1d73ea8cf50eab0b0ef0968ab23974abcd Mon Sep 17 00:00:00 2001 From: Arseny Balobanov Date: Sat, 15 Feb 2020 22:56:46 +0300 Subject: [PATCH 3/7] Test success case of using WaitForPort func. --- tools/testtool/freeport_test.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tools/testtool/freeport_test.go b/tools/testtool/freeport_test.go index 2cd4177..4314c3b 100644 --- a/tools/testtool/freeport_test.go +++ b/tools/testtool/freeport_test.go @@ -2,6 +2,10 @@ package testtool import ( "context" + "net" + "net/http" + "net/http/httptest" + "net/url" "testing" "time" @@ -15,6 +19,23 @@ func TestGetFreePort(t *testing.T) { } func TestWaitForPort(t *testing.T) { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer s.Close() + + u, err := url.Parse(s.URL) + require.Nil(t, err) + _, port, err := net.SplitHostPort(u.Host) + require.Nil(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + WaitForPort(ctx, port) + + require.NoError(t, ctx.Err()) +} + +func TestWaitForPort_timeout(t *testing.T) { p, err := GetFreePort() require.NoError(t, err) From a07059c7694ad81c7f49ee6d3f48a9ecaf8582d7 Mon Sep 17 00:00:00 2001 From: Arseny Balobanov Date: Sat, 15 Feb 2020 23:18:14 +0300 Subject: [PATCH 4/7] Replace context in WaitForPort with timeout. --- digitalclock/main_test.go | 8 +------- tools/testtool/freeport.go | 14 ++++++++------ tools/testtool/freeport_test.go | 15 ++------------- 3 files changed, 11 insertions(+), 26 deletions(-) diff --git a/digitalclock/main_test.go b/digitalclock/main_test.go index 20e8bff..83b83d8 100644 --- a/digitalclock/main_test.go +++ b/digitalclock/main_test.go @@ -1,7 +1,6 @@ package main import ( - "context" "encoding/json" "fmt" "image" @@ -58,12 +57,7 @@ func startServer(t *testing.T) (port string, stop func()) { <-done } - ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) - defer cancel() - - testtool.WaitForPort(ctx, port) - if ctx.Err() != nil { - err = ctx.Err() + if err = testtool.WaitForPort(time.Second*30, port); err != nil { stop() } diff --git a/tools/testtool/freeport.go b/tools/testtool/freeport.go index 8f07e38..b31ef87 100644 --- a/tools/testtool/freeport.go +++ b/tools/testtool/freeport.go @@ -1,7 +1,6 @@ package testtool import ( - "context" "fmt" "net" "os" @@ -29,21 +28,24 @@ func GetFreePort() (string, error) { // WaitForPort tries to connect to given local port with constant backoff. // -// Can be canceled via ctx. -func WaitForPort(ctx context.Context, port string) { +// Returns error if port is not ready after timeout. +func WaitForPort(timeout time.Duration, port string) error { + stopTimer := time.NewTimer(timeout) + defer stopTimer.Stop() + t := time.NewTicker(time.Millisecond * 100) defer t.Stop() for { select { - case <-ctx.Done(): - return + case <-stopTimer.C: + return fmt.Errorf("timeout") case <-t.C: if err := portIsReady(port); err != nil { _, _ = fmt.Fprintf(os.Stderr, "waiting for port: %s\n", err) break } - return + return nil } } } diff --git a/tools/testtool/freeport_test.go b/tools/testtool/freeport_test.go index 4314c3b..f9cbbbb 100644 --- a/tools/testtool/freeport_test.go +++ b/tools/testtool/freeport_test.go @@ -1,7 +1,6 @@ package testtool import ( - "context" "net" "net/http" "net/http/httptest" @@ -27,22 +26,12 @@ func TestWaitForPort(t *testing.T) { _, port, err := net.SplitHostPort(u.Host) require.Nil(t, err) - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - - WaitForPort(ctx, port) - - require.NoError(t, ctx.Err()) + require.NoError(t, WaitForPort(time.Second, port)) } func TestWaitForPort_timeout(t *testing.T) { p, err := GetFreePort() require.NoError(t, err) - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - - WaitForPort(ctx, p) - - require.Error(t, ctx.Err()) + require.Error(t, WaitForPort(time.Second, p)) } From c4ca3815c618e352aa2bb321a8fd09afed8d3575 Mon Sep 17 00:00:00 2001 From: Arseny Balobanov Date: Wed, 19 Feb 2020 12:22:15 +0300 Subject: [PATCH 5/7] urlshortener: adding description and solution. --- urlshortener/README.md | 99 ++++++++++++++++++++++++++++++++++++++++++ urlshortener/main.go | 7 +++ 2 files changed, 106 insertions(+) create mode 100644 urlshortener/README.md create mode 100644 urlshortener/main.go diff --git a/urlshortener/README.md b/urlshortener/README.md new file mode 100644 index 0000000..bb9425a --- /dev/null +++ b/urlshortener/README.md @@ -0,0 +1,99 @@ +## urlshortener + +В этой задаче нужно написать http сервер со следующим API: + +* POST /shorten {"url": "\"} -> {"key": "\"} +* GET /go/\ -> 302 + +GET и POST - это методы HTTP. GET запрос используется для того, чтобы получать данные, а POST - чтобы добавлять и модифицировать. + +В тело `/shorten` запроса будет передаваться json вида +``` +{"url":"https://github.com/golang/go/wiki/CodeReviewComments"} +``` + +Сервер должен ответить json'ом следующего вида: +``` +{ + "url": "https://github.com/golang/go/wiki/CodeReviewComments", + "key": "ed1De1" +} +``` + +`ed1De1` здесь - это сгенерированное сервисом число. + +После такого `/shorten` можно делать `/go/ed1De1`. +Ответ должен иметь иметь HTTP код 302. +302 указывает на то, что запрошенный ресурс был временно перемещен на другой адрес (передаваемый в HTTP header'е `Location`). + +Если открыть http://localhost:6029/go/ed1De1 в браузере, тот перенаправит на https://github.com/golang/go/wiki/CodeReviewComments. + +Сервер должен слушать порт, переданный через аргумент `--port`. + +### Примеры + +Запуск: +``` +$ urlshortener -port 6029 +``` + +Успешное добавление URL'а (200, Content-Type: application/json): +``` +$ curl -i -X POST "localhost:6029/shorten" -d '{"url":"https://github.com/golang/go/wiki/CodeReviewComments"}' +HTTP/1.1 200 OK +Content-Type: application/json +Date: Sat, 15 Feb 2020 23:35:26 GMT +Content-Length: 82 + +{"url":"https://github.com/golang/go/wiki/CodeReviewComments","key":"65ed150831"} +``` + +Невалидный json (400): +``` +$ curl -i -X POST "localhost:6029/shorten" -d '{"url":"https://github.com' +HTTP/1.1 400 Bad Request +Content-Type: text/plain; charset=utf-8 +X-Content-Type-Options: nosniff +Date: Sat, 15 Feb 2020 23:30:27 GMT +Content-Length: 16 + +invalid request +``` + +Успешный запрос (302, Location header): +``` +$ curl -i -X GET "localhost:6029/go/c1464c853a" +HTTP/1.1 302 Found +Content-Type: text/html; charset=utf-8 +Location: https://github.com/golang/go/wiki/CodeReviewComments +Date: Sat, 15 Feb 2020 23:25:26 GMT +Content-Length: 75 + +Found. +``` + +Несуществующий key (404): +``` +$ curl -i -X GET "localhost:6029/go/uaaab" +HTTP/1.1 404 Not Found +Content-Type: text/plain; charset=utf-8 +X-Content-Type-Options: nosniff +Date: Sat, 15 Feb 2020 23:26:48 GMT +Content-Length: 14 + +key not found +``` + +### Состояние + +Своё состояние сервис должен целиком хранить в памяти. +Стандартный http server на каждый запрос запускает handler в отдельной горутине (https://golang.org/pkg/net/http/#Serve), +поэтому доступ к состоянию нужно защитить. Например, это можно сделать с помощью [мьютекса](https://golang.org/pkg/sync/#Mutex). + +## Ссылки + +1. Пример web сервера и работы с общим состоянием: https://p.go.manytask.org/00-intro/lecture.slide#24 +2. протокол HTTP: https://ru.wikipedia.org/wiki/HTTP +3. http multiplexer: https://golang.org/pkg/net/http/#ServeMux +4. десериализация json'а: https://golang.org/pkg/encoding/json/#example_Unmarshal +5. генерация случайных данных: https://golang.org/pkg/math/rand/ diff --git a/urlshortener/main.go b/urlshortener/main.go new file mode 100644 index 0000000..cab7d3a --- /dev/null +++ b/urlshortener/main.go @@ -0,0 +1,7 @@ +// +build !solution + +package main + +func main() { + +} From f46a904494e36deabe9e9000ffc57712a0a02b99 Mon Sep 17 00:00:00 2001 From: Arseny Balobanov Date: Thu, 20 Feb 2020 00:26:12 +0300 Subject: [PATCH 6/7] urlshortener: adding tests. --- go.mod | 1 + go.sum | 7 ++ urlshortener/main_test.go | 200 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 urlshortener/main_test.go diff --git a/go.mod b/go.mod index 78dbe0c..1f5fe97 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module gitlab.com/slon/shad-go go 1.13 require ( + github.com/go-resty/resty/v2 v2.1.0 github.com/spf13/cobra v0.0.5 github.com/stretchr/testify v1.4.0 go.uber.org/goleak v1.0.0 diff --git a/go.sum b/go.sum index 25e688a..a48ef17 100644 --- a/go.sum +++ b/go.sum @@ -8,11 +8,15 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/go-resty/resty/v2 v2.1.0 h1:Z6IefCpUMfnvItVJaJXWv/pMiiD11So35QgwEELsldE= +github.com/go-resty/resty/v2 v2.1.0/go.mod h1:dZGr0i9PLlaaTD4H/hoZIDjQ+r6xq8mgbRzHZf7f2J8= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= @@ -47,6 +51,8 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7 h1:rTIdg5QFRR7XCaK4LCjBiPbx8j4DQRpdYMnGn/bJUEU= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -62,6 +68,7 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898 h1:/atklqdjdhuosWIl6AIbO golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/urlshortener/main_test.go b/urlshortener/main_test.go new file mode 100644 index 0000000..b67497b --- /dev/null +++ b/urlshortener/main_test.go @@ -0,0 +1,200 @@ +package main + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "reflect" + "sync" + "testing" + "time" + + "github.com/go-resty/resty/v2" + "github.com/stretchr/testify/require" + "gitlab.com/slon/shad-go/tools/testtool" +) + +const importPath = "gitlab.com/slon/shad-go/urlshortener" + +var binCache testtool.BinCache + +func TestMain(m *testing.M) { + os.Exit(func() int { + var teardown testtool.CloseFunc + binCache, teardown = testtool.NewBinCache() + defer teardown() + + return m.Run() + }()) +} + +func startServer(t *testing.T) (port string, stop func()) { + binary, err := binCache.GetBinary(importPath) + require.NoError(t, err) + + port, err = testtool.GetFreePort() + require.NoError(t, err, "unable to get free port") + + cmd := exec.Command(binary, "-port", port) + cmd.Stdout = nil + cmd.Stderr = os.Stderr + + require.NoError(t, cmd.Start()) + + done := make(chan error) + go func() { + done <- cmd.Wait() + }() + + stop = func() { + _ = cmd.Process.Kill() + <-done + } + + if err = testtool.WaitForPort(t, time.Second*5, port); err != nil { + stop() + } + + require.NoError(t, err) + return +} + +func add(t *testing.T, c *resty.Client, shortenerURL, request string) string { + type Response struct { + URL string `json:"url"` + Key string `json:"key"` + } + + resp, err := c.R(). + SetBody(map[string]interface{}{"url": request}). + SetResult(&Response{}). + Post(shortenerURL) + + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode()) + response := resp.Result().(*Response) + require.Equal(t, request, response.URL) + require.Contains(t, resp.Header().Get("Content-Type"), "application/json") + + return response.Key +} + +func TestURLShortener_redirect(t *testing.T) { + port, stop := startServer(t) + defer stop() + + var mu sync.Mutex + redirects := make(map[string]struct{}) + + redirectTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + redirects[r.RequestURI] = struct{}{} + mu.Unlock() + _, _ = fmt.Fprintln(w, "hello") + })) + + client := resty.New() + addURL := fmt.Sprintf("http://localhost:%s/shorten", port) + + requests := make(map[string]struct{}) + for i := 0; i < 10; i++ { + path := "/" + testtool.RandomName() + req := redirectTarget.URL + path + requests[path] = struct{}{} + key := add(t, client, addURL, req) + + getURL := fmt.Sprintf("http://localhost:%s/go/%s", port, key) + resp, err := client.R().Get(getURL) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode()) + } + + mu.Lock() + defer mu.Unlock() + + require.True(t, reflect.DeepEqual(requests, redirects), + fmt.Sprintf("expected: %+v, got: %+v", requests, redirects)) +} + +func TestURLShortener_badRequest(t *testing.T) { + port, stop := startServer(t) + defer stop() + + u := fmt.Sprintf("http://localhost:%s/shorten", port) + resp, err := resty.New().R(). + SetHeader("Content-Type", "application/json"). + SetBody([]byte(`{"url":"abc}`)). + Post(u) + + require.NoError(t, err) + require.Equal(t, http.StatusBadRequest, resp.StatusCode()) +} + +func TestURLShortener_badKey(t *testing.T) { + port, stop := startServer(t) + defer stop() + + u := fmt.Sprintf("http://localhost:%s/go/%s", port, testtool.RandomName()) + resp, err := resty.New(). + SetRedirectPolicy(resty.RedirectPolicyFunc(func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + })).R(). + Get(u) + + require.NoError(t, err) + require.Equal(t, http.StatusNotFound, resp.StatusCode()) +} + +func TestURLShortener_consistency(t *testing.T) { + port, stop := startServer(t) + defer stop() + + client := resty.New() + + get := func(originalURL, key string) { + getURL := fmt.Sprintf("http://localhost:%s/go/%s", port, key) + + resp, err := client. + SetRedirectPolicy(resty.RedirectPolicyFunc(func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + })).R(). + Get(getURL) + + require.NoError(t, err) + require.Equal(t, http.StatusFound, resp.StatusCode()) + require.Contains(t, resp.Header().Get("Location"), originalURL) + } + + var urls []string + for i := 0; i < 10; i++ { + urls = append(urls, testtool.RandomName()) + } + + keyToURL := make(map[string]string) + urlToKey := make(map[string]string) + + addURL := fmt.Sprintf("http://localhost:%s/shorten", port) + for _, u := range urls { + key := add(t, client, addURL, u) + + url, ok := keyToURL[key] + require.False(t, ok, fmt.Sprintf("duplicate key %s for urls [%s, %s]", key, u, url)) + + urlToKey[u] = key + keyToURL[key] = u + } + + for _, u := range urls { + get(u, urlToKey[u]) + } + + for _, u := range urls { + key := add(t, client, addURL, u) + + url, ok := keyToURL[key] + require.True(t, ok, fmt.Sprintf("different keys for the same url %s", u)) + require.Equal(t, url, u) + } +} From 4997592196c3eb2e0798143d2475e0fc2fd6a9d0 Mon Sep 17 00:00:00 2001 From: Arseny Balobanov Date: Thu, 20 Feb 2020 19:43:19 +0300 Subject: [PATCH 7/7] Releasing digitalclock and urlshortener. --- .deadlines.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.deadlines.yml b/.deadlines.yml index 294f508..e7c2dc8 100644 --- a/.deadlines.yml +++ b/.deadlines.yml @@ -12,3 +12,13 @@ score: 100 - task: fetchall score: 100 + +- group: Basics + start: 20-02-2020 18:00 + deadline: 02-03-2020 23:59 + tasks: + - task: digitalclock + score: 300 + + - task: urlshortener + score: 200