Compare commits
44 Commits
a0bad4d6e9
...
main
Author | SHA1 | Date | |
---|---|---|---|
3923758807 | |||
3cdca7cc94 | |||
e8b8166ae9 | |||
48df8ace6e | |||
8228bcdce3 | |||
a33d490896 | |||
00596a7c52 | |||
1153a0a5bd | |||
dec5f045a7 | |||
6292463e90 | |||
048e6cccac | |||
ef646bf98e | |||
6837cd9fc1 | |||
c6ef156d88 | |||
a4531bde4c | |||
5a62fef4f3 | |||
b58e2436cd | |||
7b3fb0f91c | |||
423c9e9567 | |||
c9c9b9bf7b | |||
8629b537b0 | |||
abc546178d | |||
650ec27ec9 | |||
52825e49b3 | |||
715ea6477a | |||
6da23dc641 | |||
7d751cab3d | |||
b37f8a8d4c | |||
6f853c25d0 | |||
0a75d86850 | |||
2515644d4f | |||
3a0808c599 | |||
accb2c51dc | |||
5f93a0b843 | |||
7a32f26bd5 | |||
c5582275e7 | |||
c0db9ab22b | |||
0b181ccf0f | |||
d49f952035 | |||
436bcad0e1 | |||
6ded953ec6 | |||
572c8033f6 | |||
ea6f225992 | |||
41ae820a21 |
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
clock.svg
|
||||||
|
gobytest
|
39
blogposts/blogposts.go
Normal file
39
blogposts/blogposts.go
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
package blogposts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var markdownSuffix = map[string]struct{}{
|
||||||
|
"md": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrUnknownFileType = errors.New("unknown file type, must be markdown")
|
||||||
|
|
||||||
|
func NewPostsFromFS(fileSystem fs.FS) ([]Post, error) {
|
||||||
|
dir, err := fs.ReadDir(fileSystem, ".")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var posts []Post
|
||||||
|
for _, f := range dir {
|
||||||
|
fileName := f.Name()
|
||||||
|
if !isMarkdownFile(fileName) {
|
||||||
|
return nil, ErrUnknownFileType
|
||||||
|
}
|
||||||
|
post, err := getPost(fileSystem, fileName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
posts = append(posts, post)
|
||||||
|
}
|
||||||
|
return posts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMarkdownFile(fileName string) bool {
|
||||||
|
splitted := strings.Split(fileName, ".")
|
||||||
|
_, ok := markdownSuffix[splitted[len(splitted)-1:][0]]
|
||||||
|
return ok
|
||||||
|
}
|
75
blogposts/blogposts_test.go
Normal file
75
blogposts/blogposts_test.go
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
package blogposts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NOTE: This should be a black box test outside blogposts package.
|
||||||
|
|
||||||
|
// NOTE: If we want to test the return error
|
||||||
|
//
|
||||||
|
// type StubFailingFS struct{}
|
||||||
|
//
|
||||||
|
// func (s StubFailingFS) Open(name string) (fs.File, error) {
|
||||||
|
// return nil, errors.New("fail")
|
||||||
|
// }
|
||||||
|
|
||||||
|
func TestNewBlogPosts(t *testing.T) {
|
||||||
|
const (
|
||||||
|
firstBody = `Title: Post 1
|
||||||
|
Description: Description 1
|
||||||
|
Tags: tdd, go
|
||||||
|
---
|
||||||
|
Hello
|
||||||
|
World`
|
||||||
|
secondBody = `Title: Post 2
|
||||||
|
Description: Description 2
|
||||||
|
Tags: rust, borrow-checker
|
||||||
|
---
|
||||||
|
B
|
||||||
|
L
|
||||||
|
M`
|
||||||
|
)
|
||||||
|
|
||||||
|
fs := fstest.MapFS{
|
||||||
|
"hello world.md": {Data: []byte(firstBody)},
|
||||||
|
"hello-world2.md": {Data: []byte(secondBody)},
|
||||||
|
}
|
||||||
|
|
||||||
|
posts, err := NewPostsFromFS(fs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(posts) != len(fs) {
|
||||||
|
t.Errorf("got %d posts, wanted %d posts", len(posts), len(fs))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertPost(t, posts[0], Post{
|
||||||
|
Title: "Post 1",
|
||||||
|
Description: "Description 1",
|
||||||
|
Tags: []string{"tdd", "go"},
|
||||||
|
Body: `Hello
|
||||||
|
World`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrongFile(t *testing.T) {
|
||||||
|
fs := fstest.MapFS{
|
||||||
|
"hello world.txt": {Data: []byte("Yolo")},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := NewPostsFromFS(fs)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("should be an error but not")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertPost(t testing.TB, got Post, want Post) {
|
||||||
|
t.Helper()
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("got %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
7
blogposts/hello_world.md
Normal file
7
blogposts/hello_world.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
Title: Hello, TDD world!
|
||||||
|
Description: First post on our wonderful blog
|
||||||
|
Tags: tdd, go
|
||||||
|
---
|
||||||
|
Hello world!
|
||||||
|
|
||||||
|
The body of posts starts after the `---`
|
73
blogposts/post.go
Normal file
73
blogposts/post.go
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
package blogposts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Post struct {
|
||||||
|
Title, Description, Body string
|
||||||
|
Tags []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Split a metadata line into 2 parts, one is the key, another is the
|
||||||
|
// value, check if the key exists in the key map (ex. Title, Description etc.)
|
||||||
|
// If not exist, then ignore this metadata line.
|
||||||
|
//
|
||||||
|
// WARN: What if a file doesn't have metadata, just pure Markdown ??
|
||||||
|
// We can control if we at least found title line. If at the end, it is not
|
||||||
|
// present, we consider that the post has an error.
|
||||||
|
const (
|
||||||
|
titleLine = "Title: "
|
||||||
|
descriptionLine = "Description: "
|
||||||
|
tagsLine = "Tags: "
|
||||||
|
tagSeparator = ", "
|
||||||
|
bodySeparator = "---"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getPost(fileSystem fs.FS, fileName string) (Post, error) {
|
||||||
|
postFile, err := fileSystem.Open(fileName)
|
||||||
|
if err != nil {
|
||||||
|
return Post{}, err
|
||||||
|
}
|
||||||
|
defer postFile.Close()
|
||||||
|
|
||||||
|
return newPost(postFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: Does newPost have to be coupled to an fs.File ?
|
||||||
|
// Do we use all the methods and data from this type? What do we really need?
|
||||||
|
func newPost(postFile io.Reader) (Post, error) {
|
||||||
|
scanner := bufio.NewScanner(postFile)
|
||||||
|
|
||||||
|
readMetaLine := func(tagName string) string {
|
||||||
|
scanner.Scan()
|
||||||
|
return strings.TrimPrefix(scanner.Text(), tagName)
|
||||||
|
}
|
||||||
|
|
||||||
|
post := Post{
|
||||||
|
Title: readMetaLine(titleLine),
|
||||||
|
Description: readMetaLine(descriptionLine),
|
||||||
|
Tags: strings.Split(readMetaLine(tagsLine), tagSeparator),
|
||||||
|
Body: readBody(scanner),
|
||||||
|
}
|
||||||
|
return post, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readBody(scanner *bufio.Scanner) string {
|
||||||
|
for scanner.Scan() {
|
||||||
|
if strings.TrimSpace(scanner.Text()) == bodySeparator {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rest is the body
|
||||||
|
var buf strings.Builder
|
||||||
|
for scanner.Scan() {
|
||||||
|
buf.Write(scanner.Bytes())
|
||||||
|
buf.Write([]byte{'\n'})
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(buf.String(), "\n")
|
||||||
|
}
|
113
clockface/clockface.go
Normal file
113
clockface/clockface.go
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
package clockface
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Point represents a two-dimentional Cartesian coordinate
|
||||||
|
type Point struct {
|
||||||
|
X float64
|
||||||
|
Y float64
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
hourHandLength = 50
|
||||||
|
minuteHandLength = 80
|
||||||
|
secondHandLength = 90
|
||||||
|
clockCentreX = 150
|
||||||
|
clockCentreY = 150
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
secondsInHalfClock = 30
|
||||||
|
secondsInClock = 2 * secondsInHalfClock
|
||||||
|
minutesInHalfClock = 30
|
||||||
|
minutesInClock = 2 * minutesInHalfClock
|
||||||
|
hoursInHalfClock = 6
|
||||||
|
hoursInClock = 2 * hoursInHalfClock
|
||||||
|
)
|
||||||
|
|
||||||
|
const svgStart = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
viewBox="0 0 300 300"
|
||||||
|
version="2.0">`
|
||||||
|
|
||||||
|
const bezel = `<circle cx="150" cy="150" r="100" style="fill:#fff;stroke:#000;stroke-width:5px;"/>`
|
||||||
|
|
||||||
|
const svgEnd = `</svg>`
|
||||||
|
|
||||||
|
func SVGWriter(w io.Writer, t time.Time) {
|
||||||
|
io.WriteString(w, svgStart)
|
||||||
|
io.WriteString(w, bezel)
|
||||||
|
secondHand(w, t)
|
||||||
|
minuteHand(w, t)
|
||||||
|
hourHand(w, t)
|
||||||
|
io.WriteString(w, svgEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecondHand is the unit vector of the second hand of an analogue clock at the time `t` represented as a Point
|
||||||
|
func secondHand(w io.Writer, t time.Time) {
|
||||||
|
p := secondHandPoint(t)
|
||||||
|
makeHand(w, secondHandLength, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MinuteHand is the unit vector of the minute hand of an analogue clock at the time `t` represented as a Point
|
||||||
|
func minuteHand(w io.Writer, t time.Time) {
|
||||||
|
p := minuteHandPoint(t)
|
||||||
|
makeHand(w, minuteHandLength, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MinuteHand is the unit vector of the minute hand of an analogue clock at the time `t` represented as a Point
|
||||||
|
func hourHand(w io.Writer, t time.Time) {
|
||||||
|
p := hourHandPoint(t)
|
||||||
|
makeHand(w, hourHandLength, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeHand(w io.Writer, length float64, p Point) {
|
||||||
|
p = Point{p.X * length, p.Y * length} // scale
|
||||||
|
p = Point{p.X, -p.Y} // flip
|
||||||
|
p = Point{p.X + clockCentreX, p.Y + clockCentreY} // translate
|
||||||
|
fmt.Fprintf(
|
||||||
|
w,
|
||||||
|
`<line x1="150" y1="150" x2="%f" y2="%f" style="fill:none;stroke:#f00;stroke-width:3px;"/>`,
|
||||||
|
p.X,
|
||||||
|
p.Y,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hoursInRadians(t time.Time) float64 {
|
||||||
|
return (minutesInRadians(t) / hoursInClock) + math.Pi/(hoursInHalfClock/float64(t.Hour()%hoursInClock))
|
||||||
|
}
|
||||||
|
|
||||||
|
func hourHandPoint(t time.Time) Point {
|
||||||
|
return angleToPoint(hoursInRadians(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func minutesInRadians(t time.Time) float64 {
|
||||||
|
return (secondsInRadians(t) / minutesInClock) + math.Pi/(minutesInHalfClock/float64(t.Minute()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func minuteHandPoint(t time.Time) Point {
|
||||||
|
return angleToPoint(minutesInRadians(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func secondsInRadians(t time.Time) float64 {
|
||||||
|
return math.Pi / (secondsInHalfClock / float64(t.Second()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func secondHandPoint(t time.Time) Point {
|
||||||
|
return angleToPoint(secondsInRadians(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func angleToPoint(angle float64) Point {
|
||||||
|
x := math.Sin(angle)
|
||||||
|
y := math.Cos(angle)
|
||||||
|
|
||||||
|
return Point{x, y}
|
||||||
|
}
|
123
clockface/clockface_acceptance_test.go
Normal file
123
clockface/clockface_acceptance_test.go
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
package clockface
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/xml"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Svg struct {
|
||||||
|
XMLName xml.Name `xml:"svg"`
|
||||||
|
Xmlns string `xml:"xmlns,attr"`
|
||||||
|
Width string `xml:"width,attr"`
|
||||||
|
Height string `xml:"height,attr"`
|
||||||
|
ViewBox string `xml:"viewBox,attr"`
|
||||||
|
Version string `xml:"version,attr"`
|
||||||
|
Circle Circle `xml:"circle"`
|
||||||
|
Line []Line `xml:"line"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Circle struct {
|
||||||
|
Cx float64 `xml:"cx,attr"`
|
||||||
|
Cy float64 `xml:"cy,attr"`
|
||||||
|
R float64 `xml:"r,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Line struct {
|
||||||
|
X1 float64 `xml:"x1,attr"`
|
||||||
|
Y1 float64 `xml:"y1,attr"`
|
||||||
|
X2 float64 `xml:"x2,attr"`
|
||||||
|
Y2 float64 `xml:"y2,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSVGWriterSecondHand(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
time time.Time
|
||||||
|
line Line
|
||||||
|
}{
|
||||||
|
{simpleTime(0, 0, 0), Line{150, 150, 150, 60}},
|
||||||
|
{simpleTime(0, 0, 30), Line{150, 150, 150, 240}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(testName(c.time), func(t *testing.T) {
|
||||||
|
b := bytes.Buffer{}
|
||||||
|
SVGWriter(&b, c.time)
|
||||||
|
|
||||||
|
svg := Svg{}
|
||||||
|
xml.Unmarshal(b.Bytes(), &svg)
|
||||||
|
|
||||||
|
if !containsLine(c.line, svg.Line) {
|
||||||
|
t.Errorf(
|
||||||
|
"Expected to find the second hand line %+v in the SVG lines %v",
|
||||||
|
c.line,
|
||||||
|
svg.Line,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSVGWriterMinuteHand(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
time time.Time
|
||||||
|
line Line
|
||||||
|
}{
|
||||||
|
{simpleTime(0, 0, 0), Line{150, 150, 150, 70}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(testName(c.time), func(t *testing.T) {
|
||||||
|
b := bytes.Buffer{}
|
||||||
|
SVGWriter(&b, c.time)
|
||||||
|
|
||||||
|
svg := Svg{}
|
||||||
|
xml.Unmarshal(b.Bytes(), &svg)
|
||||||
|
|
||||||
|
if !containsLine(c.line, svg.Line) {
|
||||||
|
t.Errorf(
|
||||||
|
"Expected to find the minute hand line %+v in the SVG lines %v",
|
||||||
|
c.line,
|
||||||
|
svg.Line,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSVGWriterHourHand(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
time time.Time
|
||||||
|
line Line
|
||||||
|
}{
|
||||||
|
{simpleTime(6, 0, 0), Line{150, 150, 150, 200}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(testName(c.time), func(t *testing.T) {
|
||||||
|
b := bytes.Buffer{}
|
||||||
|
SVGWriter(&b, c.time)
|
||||||
|
|
||||||
|
svg := Svg{}
|
||||||
|
xml.Unmarshal(b.Bytes(), &svg)
|
||||||
|
|
||||||
|
if !containsLine(c.line, svg.Line) {
|
||||||
|
t.Errorf(
|
||||||
|
"Expected to find the hour hand line %+v in the SVG lines %v",
|
||||||
|
c.line,
|
||||||
|
svg.Line,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsLine(l Line, ls []Line) bool {
|
||||||
|
for _, line := range ls {
|
||||||
|
if line == l {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
145
clockface/clockface_test.go
Normal file
145
clockface/clockface_test.go
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
package clockface
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHoursInRadians(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
time time.Time
|
||||||
|
angle float64
|
||||||
|
}{
|
||||||
|
{simpleTime(6, 0, 0), math.Pi},
|
||||||
|
{simpleTime(0, 0, 0), 0},
|
||||||
|
{simpleTime(21, 0, 0), math.Pi * 1.5},
|
||||||
|
{simpleTime(0, 1, 30), math.Pi / ((6 * 60 * 60) / 90)},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(testName(c.time), func(t *testing.T) {
|
||||||
|
got := hoursInRadians(c.time)
|
||||||
|
|
||||||
|
if !roughlyEqualFloat64(c.angle, got) {
|
||||||
|
t.Fatalf("Wanted %v radians, but got %v", c.angle, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHourHandPoint(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
time time.Time
|
||||||
|
point Point
|
||||||
|
}{
|
||||||
|
{simpleTime(6, 0, 0), Point{0, -1}},
|
||||||
|
{simpleTime(9, 0, 0), Point{-1, 0}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(testName(c.time), func(t *testing.T) {
|
||||||
|
got := hourHandPoint(c.time)
|
||||||
|
if !roughlyEqualPoint(got, c.point) {
|
||||||
|
t.Fatalf("Wanted %v Point, but got %v", c.point, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMinutesInRadians(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
time time.Time
|
||||||
|
angle float64
|
||||||
|
}{
|
||||||
|
{simpleTime(0, 30, 0), math.Pi},
|
||||||
|
{simpleTime(0, 0, 7), 7 * (math.Pi / (30 * 60))},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(testName(c.time), func(t *testing.T) {
|
||||||
|
got := minutesInRadians(c.time)
|
||||||
|
|
||||||
|
if c.angle != got {
|
||||||
|
t.Fatalf("Wanted %v radians, but got %v", c.angle, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMinuteHandPoint(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
time time.Time
|
||||||
|
point Point
|
||||||
|
}{
|
||||||
|
{simpleTime(0, 30, 0), Point{0, -1}},
|
||||||
|
{simpleTime(0, 45, 0), Point{-1, 0}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(testName(c.time), func(t *testing.T) {
|
||||||
|
got := minuteHandPoint(c.time)
|
||||||
|
if !roughlyEqualPoint(got, c.point) {
|
||||||
|
t.Fatalf("Wanted %v Point, but got %v", c.point, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecondsInRadians(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
time time.Time
|
||||||
|
angle float64
|
||||||
|
}{
|
||||||
|
{simpleTime(0, 0, 30), math.Pi},
|
||||||
|
{simpleTime(0, 0, 0), 0},
|
||||||
|
{simpleTime(0, 0, 45), (math.Pi / 2) * 3},
|
||||||
|
{simpleTime(0, 0, 7), (math.Pi / 30) * 7},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(testName(c.time), func(t *testing.T) {
|
||||||
|
got := secondsInRadians(c.time)
|
||||||
|
|
||||||
|
if c.angle != got {
|
||||||
|
t.Fatalf("Wanted %v radians, but got %v", c.angle, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecondHandPoint(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
time time.Time
|
||||||
|
point Point
|
||||||
|
}{
|
||||||
|
{simpleTime(0, 0, 30), Point{0, -1}},
|
||||||
|
{simpleTime(0, 0, 45), Point{-1, 0}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(testName(c.time), func(t *testing.T) {
|
||||||
|
got := secondHandPoint(c.time)
|
||||||
|
if !roughlyEqualPoint(got, c.point) {
|
||||||
|
t.Fatalf("Wanted %v Point, but got %v", c.point, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func simpleTime(hours, minutes, seconds int) time.Time {
|
||||||
|
return time.Date(321, time.October, 28, hours, minutes, seconds, 0, time.UTC)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testName(t time.Time) string {
|
||||||
|
return t.Format("15:04:05")
|
||||||
|
}
|
||||||
|
|
||||||
|
func roughlyEqualFloat64(a, b float64) bool {
|
||||||
|
const equalityThreshold = 1e-7
|
||||||
|
return math.Abs(a-b) < equalityThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
func roughlyEqualPoint(a, b Point) bool {
|
||||||
|
return roughlyEqualFloat64(a.X, b.X) && roughlyEqualFloat64(a.Y, b.Y)
|
||||||
|
}
|
23
context/context.go
Normal file
23
context/context.go
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
package context
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Store interface {
|
||||||
|
Fetch(ctx context.Context) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Server(store Store) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data, err := store.Fetch(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, data)
|
||||||
|
}
|
||||||
|
}
|
98
context/context_test.go
Normal file
98
context/context_test.go
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
package context
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SpyStore struct {
|
||||||
|
response string
|
||||||
|
t *testing.T
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SpyStore) Fetch(ctx context.Context) (string, error) {
|
||||||
|
data := make(chan string, 1)
|
||||||
|
go func() {
|
||||||
|
var result string
|
||||||
|
for _, c := range s.response {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.Println("spy store got cancelled")
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
result += string(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data <- result
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", ctx.Err()
|
||||||
|
case res := <-data:
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpyResponseWriter struct {
|
||||||
|
written bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SpyResponseWriter) Header() http.Header {
|
||||||
|
s.written = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SpyResponseWriter) Write([]byte) (int, error) {
|
||||||
|
s.written = true
|
||||||
|
return 0, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SpyResponseWriter) WriteHeader(statusCode int) {
|
||||||
|
s.written = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer(t *testing.T) {
|
||||||
|
t.Run("basic get store", func(t *testing.T) {
|
||||||
|
data := "hello, world"
|
||||||
|
store := &SpyStore{response: data, t: t}
|
||||||
|
srv := Server(store)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
|
||||||
|
srv.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Body.String() != data {
|
||||||
|
t.Errorf(`got "%s", want "%s"`, response.Body.String(), data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("tells store to cancel work if request is cancelled", func(t *testing.T) {
|
||||||
|
data := "hello, world"
|
||||||
|
store := &SpyStore{response: data, t: t}
|
||||||
|
srv := Server(store)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
|
||||||
|
cancellingCtx, cancel := context.WithCancel(request.Context())
|
||||||
|
// Wait 5ms to call cancel
|
||||||
|
time.AfterFunc(5*time.Millisecond, cancel)
|
||||||
|
|
||||||
|
request = request.WithContext(cancellingCtx)
|
||||||
|
|
||||||
|
response := &SpyResponseWriter{}
|
||||||
|
|
||||||
|
srv.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.written {
|
||||||
|
t.Error("a response should not have been written")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
22
counter/counter.go
Normal file
22
counter/counter.go
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
package counter
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
type Counter struct {
|
||||||
|
count int
|
||||||
|
mutex sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCounter() *Counter {
|
||||||
|
return &Counter{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Counter) Inc() {
|
||||||
|
c.mutex.Lock()
|
||||||
|
defer c.mutex.Unlock()
|
||||||
|
c.count++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Counter) Value() int {
|
||||||
|
return c.count
|
||||||
|
}
|
44
counter/counter_test.go
Normal file
44
counter/counter_test.go
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
package counter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// assertCounter
|
||||||
|
// XXX: a copy of Counter is created, but we shouldn't copy a mutex. Use pointer instead.
|
||||||
|
func assertCounter(t testing.TB, got *Counter, want int) {
|
||||||
|
t.Helper()
|
||||||
|
if got.Value() != want {
|
||||||
|
t.Errorf("got %d, want %d", got.Value(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCounter(t *testing.T) {
|
||||||
|
t.Run("incrementing the counter 3 times leaves it at 3", func(t *testing.T) {
|
||||||
|
counter := NewCounter()
|
||||||
|
|
||||||
|
counter.Inc()
|
||||||
|
counter.Inc()
|
||||||
|
counter.Inc()
|
||||||
|
|
||||||
|
assertCounter(t, counter, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("it runs safely concurrently", func(t *testing.T) {
|
||||||
|
wantedCount := 1000
|
||||||
|
counter := NewCounter()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(wantedCount)
|
||||||
|
|
||||||
|
for i := 0; i < wantedCount; i++ {
|
||||||
|
go func() {
|
||||||
|
counter.Inc()
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
assertCounter(t, counter, wantedCount)
|
||||||
|
})
|
||||||
|
}
|
12
main.go
12
main.go
@ -1,14 +1,20 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"gobytest/countdown"
|
"gobytest/clockface"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// greet.Greet(os.Stdout, "Elodie")
|
// greet.Greet(os.Stdout, "Elodie")
|
||||||
sleeper := countdown.NewConfigurableSleeper(1*time.Second, time.Sleep)
|
// sleeper := countdown.NewConfigurableSleeper(1*time.Second, time.Sleep)
|
||||||
|
|
||||||
countdown.Countdown(os.Stdout, sleeper)
|
// countdown
|
||||||
|
// countdown.Countdown(os.Stdout, sleeper)
|
||||||
|
|
||||||
|
// clockface
|
||||||
|
t := time.Now()
|
||||||
|
|
||||||
|
clockface.SVGWriter(os.Stdout, t)
|
||||||
}
|
}
|
||||||
|
58
roman/roman.go
Normal file
58
roman/roman.go
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
package roman
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RomanNumeral struct {
|
||||||
|
Value uint16
|
||||||
|
Symbol string
|
||||||
|
}
|
||||||
|
|
||||||
|
var allRomanNumerals = []RomanNumeral{
|
||||||
|
{1000, "M"},
|
||||||
|
{900, "CM"},
|
||||||
|
{500, "D"},
|
||||||
|
{400, "CD"},
|
||||||
|
{900, "CM"},
|
||||||
|
{100, "C"},
|
||||||
|
{90, "XC"},
|
||||||
|
{50, "L"},
|
||||||
|
{40, "XL"},
|
||||||
|
{10, "X"},
|
||||||
|
{9, "IX"},
|
||||||
|
{5, "V"},
|
||||||
|
{4, "IV"},
|
||||||
|
{1, "I"},
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrNumberOutOfRange = errors.New("numbers should not be bigger than 3999")
|
||||||
|
|
||||||
|
func ConvertToRoman(arabic uint16) (string, error) {
|
||||||
|
if arabic > 3999 {
|
||||||
|
return "", ErrNumberOutOfRange
|
||||||
|
}
|
||||||
|
var converted strings.Builder
|
||||||
|
for _, numeral := range allRomanNumerals {
|
||||||
|
for arabic >= numeral.Value {
|
||||||
|
converted.WriteString(numeral.Symbol)
|
||||||
|
arabic -= numeral.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return converted.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// XXX: This convert function doesn't check the validity of the roman numerals string
|
||||||
|
func ConvertToArabic(roman string) uint16 {
|
||||||
|
var converted uint16
|
||||||
|
for _, numeral := range allRomanNumerals {
|
||||||
|
for strings.HasPrefix(roman, numeral.Symbol) {
|
||||||
|
converted += numeral.Value
|
||||||
|
// roman = roman[len(numeral.Symbol):]
|
||||||
|
roman = strings.TrimPrefix(roman, numeral.Symbol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return converted
|
||||||
|
}
|
85
roman/roman_test.go
Normal file
85
roman/roman_test.go
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
package roman
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"testing/quick"
|
||||||
|
)
|
||||||
|
|
||||||
|
var cases = []struct {
|
||||||
|
Arabic uint16
|
||||||
|
Roman string
|
||||||
|
}{
|
||||||
|
{Arabic: 1, Roman: "I"},
|
||||||
|
{Arabic: 2, Roman: "II"},
|
||||||
|
{Arabic: 3, Roman: "III"},
|
||||||
|
{Arabic: 4, Roman: "IV"},
|
||||||
|
{Arabic: 5, Roman: "V"},
|
||||||
|
{Arabic: 6, Roman: "VI"},
|
||||||
|
{Arabic: 7, Roman: "VII"},
|
||||||
|
{Arabic: 8, Roman: "VIII"},
|
||||||
|
{Arabic: 9, Roman: "IX"},
|
||||||
|
{Arabic: 10, Roman: "X"},
|
||||||
|
{Arabic: 14, Roman: "XIV"},
|
||||||
|
{Arabic: 18, Roman: "XVIII"},
|
||||||
|
{Arabic: 20, Roman: "XX"},
|
||||||
|
{Arabic: 39, Roman: "XXXIX"},
|
||||||
|
{Arabic: 40, Roman: "XL"},
|
||||||
|
{Arabic: 47, Roman: "XLVII"},
|
||||||
|
{Arabic: 49, Roman: "XLIX"},
|
||||||
|
{Arabic: 50, Roman: "L"},
|
||||||
|
{Arabic: 100, Roman: "C"},
|
||||||
|
{Arabic: 90, Roman: "XC"},
|
||||||
|
{Arabic: 400, Roman: "CD"},
|
||||||
|
{Arabic: 500, Roman: "D"},
|
||||||
|
{Arabic: 900, Roman: "CM"},
|
||||||
|
{Arabic: 1000, Roman: "M"},
|
||||||
|
{Arabic: 1984, Roman: "MCMLXXXIV"},
|
||||||
|
{Arabic: 3999, Roman: "MMMCMXCIX"},
|
||||||
|
{Arabic: 2014, Roman: "MMXIV"},
|
||||||
|
{Arabic: 1006, Roman: "MVI"},
|
||||||
|
{Arabic: 798, Roman: "DCCXCVIII"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRomanNemerals(t *testing.T) {
|
||||||
|
for _, test := range cases {
|
||||||
|
t.Run(fmt.Sprintf("%d gets converted to %q", test.Arabic, test.Roman), func(t *testing.T) {
|
||||||
|
got, _ := ConvertToRoman(test.Arabic)
|
||||||
|
want := test.Roman
|
||||||
|
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertingToArabic(t *testing.T) {
|
||||||
|
for _, test := range cases {
|
||||||
|
t.Run(fmt.Sprintf("%q gets converted to %d", test.Roman, test.Arabic), func(t *testing.T) {
|
||||||
|
got := ConvertToArabic(test.Roman)
|
||||||
|
want := test.Arabic
|
||||||
|
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("got %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPropertiesOfConversion(t *testing.T) {
|
||||||
|
assertion := func(arabic uint16) bool {
|
||||||
|
roman, err := ConvertToRoman(arabic)
|
||||||
|
if arabic > 3999 {
|
||||||
|
return err == ErrNumberOutOfRange
|
||||||
|
}
|
||||||
|
fromRoman := ConvertToArabic(roman)
|
||||||
|
return fromRoman == arabic
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := quick.Check(assertion, &quick.Config{
|
||||||
|
MaxCount: 1000,
|
||||||
|
}); err != nil {
|
||||||
|
t.Error("failed checks", err)
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user