Mathematics

For all the power of modern computers to perform huge sums atlightning speed, the average developer rarely uses any mathematicsto do their job. But not today! Today we’ll use mathematics tosolve a real problem. And not boring mathematics - we’re goingto use trigonometry and vectors and all sorts of stuff that youalways said you’d never have to use after highschool.

The Problem

You want to make an SVG of a clock. Not a digital clock - no, thatwould be easy - an analogue clock, with hands. You’re not looking for anythingfancy, just a nice function that takes a Time from the time package andspits out an SVG of a clock with all the hands - hour, minute and second -pointing in the right direction. How hard can that be?

First we’re going to need an SVG of a clock for us to play with. SVGs are afantastic image format to manipulate programmatically because they’re written asa series of shapes, described in XML. So this clock:

an svg of a clock

is described like this:

  1. <?xml version="1.0" encoding="UTF-8" standalone="no"?>
  2. <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
  3. <svg xmlns="http://www.w3.org/2000/svg"
  4. width="100%"
  5. height="100%"
  6. viewBox="0 0 300 300"
  7. version="2.0">
  8. <!-- bezel -->
  9. <circle cx="150" cy="150" r="100" style="fill:#fff;stroke:#000;stroke-width:5px;"/>
  10. <!-- hour hand -->
  11. <line x1="150" y1="150" x2="114.150000" y2="132.260000"
  12. style="fill:none;stroke:#000;stroke-width:7px;"/>
  13. <!-- minute hand -->
  14. <line x1="150" y1="150" x2="101.290000" y2="99.730000"
  15. style="fill:none;stroke:#000;stroke-width:7px;"/>
  16. <!-- second hand -->
  17. <line x1="150" y1="150" x2="77.190000" y2="202.900000"
  18. style="fill:none;stroke:#f00;stroke-width:3px;"/>
  19. </svg>

It’s a circle with three lines, each of the lines starting in the middle of thecircle (x=150, y=150), and ending some distance away.

So what we’re going to do is reconstruct the above somehow, but change the linesso they point in the appropriate directions for a given time.

An Acceptance Test

Before we get too stuck in, lets think about an acceptance test. We’ve got anexample clock, so let’s think about what the important parameters are going to be.

  1. <line x1="150" y1="150" x2="114.150000" y2="132.260000"
  2. style="fill:none;stroke:#000;stroke-width:7px;"/>

The centre of the clock (the attributes x1 and y1 for this line) is the samefor each hand of the clock. The numbers that need to change for each hand of theclock - the parameters to whatever builds the SVG - are the x2 and y2attributes. We’ll need an X and a Y for each of the hands of the clock.

I could think about more parameters - the radius of the clockface circle, thesize of the SVG, the colours of the hands, their shape, etc… but it’s betterto start off by solving a simple, concrete problem with a simple, concretesolution, and then to start adding parameters to make it generalised.

So we’ll say that

  • every clock has a centre of (150, 150)
  • the hour hand is 50 long
  • the minute hand is 80 long
  • the second hand is 90 long.

A thing to note about SVGs: the origin - point (0,0) - is at the top left handcorner, not the bottom left as we might expect. It’ll be important to rememberthis when we’re working out where what numbers to plug in to our lines.

Finally, I’m not deciding how to construct the SVG - we could use a templatefrom the text/template package, or we could just send bytes intoa bytes.Buffer or a writer. But we know we’ll need those numbers, so let’sfocus on testing something that creates them.

Write the test first

So my first test looks like this:

  1. package clockface_test
  2. import (
  3. "testing"
  4. "time"
  5. "github.com/gypsydave5/learn-go-with-tests/math/v1/clockface"
  6. )
  7. func TestSecondHandAtMidnight(t *testing.T) {
  8. tm := time.Date(1337, time.January, 1, 0, 0, 0, 0, time.UTC)
  9. want := clockface.Point{X: 150, Y: 150 - 90}
  10. got := clockface.SecondHand(tm)
  11. if got != want {
  12. t.Errorf("Got %v, wanted %v", got, want)
  13. }
  14. }

Remember how SVGs plot their coordinates from the top left hand corner? To placethe second hand at midnight we expect that it hasn’t moved from the centre ofthe clockface on the X axis - still 150 - and the Y axis is the length of thehand ‘up’ from the centre; 150 minus 90.

Try to run the test

This drives out the expected failures around the missing functions and types:

  1. --- FAIL: TestSecondHandAtMidnight (0.00s)
  2. # github.com/gypsydave5/learn-go-with-tests/math/v1/clockface_test [github.com/gypsydave5/learn-go-with-tests/math/v1/clockface.test]
  3. ./clockface_test.go:13:10: undefined: clockface.Point
  4. ./clockface_test.go:14:9: undefined: clockface.SecondHand
  5. FAIL github.com/gypsydave5/learn-go-with-tests/math/v1/clockface [build failed]

So a Point where the tip of the second hand should go, and a function to get it.

Write the minimal amount of code for the test to run and check the failing test output

Let’s implement those types to get the code to compile

  1. package clockface
  2. import "time"
  3. // A Point represents a two dimensional Cartesian coordinate
  4. type Point struct {
  5. X float64
  6. Y float64
  7. }
  8. // SecondHand is the unit vector of the second hand of an analogue clock at time `t`
  9. // represented as a Point.
  10. func SecondHand(t time.Time) Point {
  11. return Point{}
  12. }

and now we get

  1. --- FAIL: TestSecondHandAtMidnight (0.00s)
  2. clockface_test.go:17: Got {0 0}, wanted {150 60}
  3. FAIL
  4. exit status 1
  5. FAIL github.com/gypsydave5/learn-go-with-tests/math/v1/clockface 0.006s

Write enough code to make it pass

When we get the expected failure, we can fill in the return value of HandsAt:

  1. // SecondHand is the unit vector of the second hand of an analogue clock at time `t`
  2. // represented as a Point.
  3. func SecondHand(t time.Time) Point {
  4. return Point{150, 60}
  5. }

Behold, a passing test.

  1. PASS
  2. ok github.com/gypsydave5/learn-go-with-tests/math/v1/clockface 0.006s

Refactor

No need to refactor yet - there’s barely enough code!

Repeat for new requirements

We probably need to do some work here that doesn’t just involve returninga clock that shows midnight for every time…

Write the test first

  1. func TestSecondHandAt30Seconds(t *testing.T) {
  2. tm := time.Date(1337, time.January, 1, 0, 0, 30, 0, time.UTC)
  3. want := clockface.Point{X: 150, Y: 150 + 90}
  4. got := clockface.SecondHand(tm)
  5. if got != want {
  6. t.Errorf("Got %v, wanted %v", got, want)
  7. }
  8. }

Same idea, but now the second hand is pointing downwards so we add thelength to the Y axis.

This will compile… but how do we make it pass?

Thinking time

How are we going to solve this problem?

Every minute the second hand goes through the same 60 states, pointing in 60different directions. When it’s 0 seconds it points to the top of the clockface,when it’s 30 seconds it points to the bottom of the clockface. Easy enough.

So if I wanted to think about in what direction the second hand was pointing at,say, 37 seconds, I’d want the angle between 12 o’clock and 37/60ths around thecircle. In degrees this is (360 / 60 ) * 37 = 222, but it’s easier just toremember that it’s 37/60 of a complete rotation.

But the angle is only half the story; we need to know the X and Y coordinatethat the tip of the second hand is pointing at. How can we work that out?

Math

Imagine a circle with a radius of 1 drawn around the origin - the coordinate 0, 0.

picture of the unit circle

This is called the ‘unit circle’ because… well, the radius is 1 unit!

The circumference of the circle is made of points on the grid - morecoordinates. The x and y components of each of these coordinates forma triangle, the hypotenuse of which is always 1 - the radius of the circle

picture of the unit circle with a point defined on the circumference

Now, trigonometry will let us work out the lengths of X and Y for each triangleif we know the angle they make with the origin. The X coordinate will be cos(a),and the Y coordinate will be sin(a), where a is the angle made between the lineand the (positive) x axis.

picture of the unit circle with the x and y elements of a ray defined as cos(a) and sin(a) respectively, where a is the angle made by the ray with the x axis

(If you don’t believe this, go and look at Wikipedia…)

One final twist - because we want to measure the angle from 12 o’clock ratherthan from the X axis (3 o’clock), we need to swap the axis around; nowx = sin(a) and y = cos(a).

unit circle ray defined from by angle from y axis

So now we know how to get the angle of the second hand (1/60th of a circle foreach second) and the X and Y coordinates. We’ll need functions for both sinand cos.

math

Happily the Go math package has both, with one small snag we’ll need to getour heads around; if we look at the description of math.Cos:

Cos returns the cosine of the radian argument x.

It wants the angle to be in radians. So what’s a radian? Instead of defining the full turn of a circle to be made up of 360 degrees, we define a full turn as being 2π radians. There are good reasons to do this that we won’t go in to.[^2]

Now that we’ve done some reading, some learning and some thinking, we can writeour next test.

Write the test first

All this maths is hard and confusing. I’m not confident I understand what’sgoing on - so let’s write a test! We don’t need to solve the whole problem inone go - let’s start off with working out the correct angle, in radians, for thesecond hand at a particular time.

I’m going to write these tests within the clockface package; they may neverget exported, and they may get deleted (or moved) once I have a better grip onwhat’s going on.

I’m also going to comment out the acceptance test that I was working on whileI’m working on these tests - I don’t want to get distracted by that test whileI’m getting this one to pass.

  1. package clockface
  2. import (
  3. "math"
  4. "testing"
  5. "time"
  6. )
  7. func TestSecondsInRadians(t *testing.T) {
  8. thirtySeconds := time.Date(312, time.October, 28, 0, 0, 30, 0, time.UTC)
  9. want := math.Pi
  10. got := secondsInRadians(thirtySeconds)
  11. if want != got {
  12. t.Fatalf("Wanted %v radians, but got %v", want, got)
  13. }
  14. }

Here we’re testing that 30 seconds past the minute should put thesecond hand at halfway around the clock. And it’s our first use ofthe math package! If a full turn of a circle is 2π radians, weknow that halfway round should just be π radians. math.Pi providesus with a value for π.

Try to run the test

  1. # github.com/gypsydave5/learn-go-with-tests/math/v2/clockface [github.com/gypsydave5/learn-go-with-tests/math/v2/clockface.test]
  2. ./clockface_test.go:12:9: undefined: secondsInRadians
  3. FAIL github.com/gypsydave5/learn-go-with-tests/math/v2/clockface [build failed]

Write the minimal amount of code for the test to run and check the failing test output

  1. func secondsInRadians(t time.Time) float64 {
  2. return 0
  3. }
  1. --- FAIL: TestSecondsInRadians (0.00s)
  2. clockface_test.go:15: Wanted 3.141592653589793 radians, but got 0
  3. FAIL
  4. exit status 1
  5. FAIL github.com/gypsydave5/learn-go-with-tests/math/v2/clockface 0.007s

Write enough code to make it pass

  1. func secondsInRadians(t time.Time) float64 {
  2. return math.Pi
  3. }
  1. PASS
  2. ok github.com/gypsydave5/learn-go-with-tests/math/v2/clockface 0.011s

Refactor

Nothing needs refactoring yet

Repeat for new requirements

Now we can extend the test to cover a few more scenarios. I’m going to skipforward a bit and show some already refactored test code - it should be clearenough how I got where I want to.

  1. func TestSecondsInRadians(t *testing.T) {
  2. cases := []struct {
  3. time time.Time
  4. angle float64
  5. }{
  6. {simpleTime(0, 0, 30), math.Pi},
  7. {simpleTime(0, 0, 0), 0},
  8. {simpleTime(0, 0, 45), (math.Pi / 2) * 3},
  9. {simpleTime(0, 0, 7), (math.Pi / 30) * 7},
  10. }
  11. for _, c := range cases {
  12. t.Run(testName(c.time), func(t *testing.T) {
  13. got := secondsInRadians(c.time)
  14. if got != c.angle {
  15. t.Fatalf("Wanted %v radians, but got %v", c.angle, got)
  16. }
  17. })
  18. }
  19. }

I added a couple of helper functions to make writing this table based testa little less tedious. testName converts a time into a digital watchformat (HH:MM:SS), and simpleTime constructs a time.Time using only theparts we actually care about (again, hours, minutes and seconds).[^1]

  1. func simpleTime(hours, minutes, seconds int) time.Time {
  2. return time.Date(312, time.October, 28, hours, minutes, seconds, 0, time.UTC)
  3. }
  4. func testName(t time.Time) string {
  5. return t.Format("15:04:05")
  6. }

These two functions should help make these tests (and future tests) a littleeasier to write and maintain.

This gives us some nice test output:

  1. --- FAIL: TestSecondsInRadians (0.00s)
  2. --- FAIL: TestSecondsInRadians/00:00:00 (0.00s)
  3. clockface_test.go:24: Wanted 0 radians, but got 3.141592653589793
  4. --- FAIL: TestSecondsInRadians/00:00:45 (0.00s)
  5. clockface_test.go:24: Wanted 4.71238898038469 radians, but got 3.141592653589793
  6. --- FAIL: TestSecondsInRadians/00:00:07 (0.00s)
  7. clockface_test.go:24: Wanted 0.7330382858376184 radians, but got 3.141592653589793
  8. FAIL
  9. exit status 1
  10. FAIL github.com/gypsydave5/learn-go-with-tests/math/v3/clockface 0.007s

Time to implement all of that maths stuff we were talking about above:

  1. func secondsInRadians(t time.Time) float64 {
  2. return float64(t.Second()) * (math.Pi / 30)
  3. }

One second is (2π / 60) radians… cancel out the 2 and we get π/30 radians.Multiply that by the number of seconds (as a float64) and we should now haveall the tests passing…

  1. --- FAIL: TestSecondsInRadians (0.00s)
  2. --- FAIL: TestSecondsInRadians/00:00:30 (0.00s)
  3. clockface_test.go:24: Wanted 3.141592653589793 radians, but got 3.1415926535897936
  4. FAIL
  5. exit status 1
  6. FAIL github.com/gypsydave5/learn-go-with-tests/math/v3/clockface 0.006s

Wait, what?

Floats are horrible

Floating point arithmetic is notoriously inaccurate. Computerscan only really handle integers, and rational numbers to some extent. Decimalnumbers start to become inaccurate, especially when we factor them up and downas we are in the secondsInRadians function. By dividing math.Pi by 30 andthen by multiplying it by 30 we’ve ended up with a number that’s no longer thesame as math.Pi.

There are two ways around this:

  1. Live with the it
  2. Refactor our function by refactoring our equation

Now (1) may not seem all that appealing, but it’s often the only way to makefloating point equality work. Being inaccurate by some infinitessimal fractionis frankly not going to matter for the purposes of drawing a clockface, so wecould write a function that defines a ‘close enough’ equality for our angles.But there’s a simple way we can get the accuracy back: we rearrange the equationso that we’re no longer dividing down and then multiplying up. We can do it allby just dividing.

So instead of

  1. numberOfSeconds * π / 30

we can write

  1. π / (30 / numberOfSeconds)

which is equivalent.

In Go:

  1. func secondsInRadians(t time.Time) float64 {
  2. return (math.Pi / (30 / (float64(t.Second()))))
  3. }

And we get a pass.

  1. PASS
  2. ok github.com/gypsydave5/learn-go-with-tests/math/v2/clockface 0.005s

Repeat for new requirements

So we’ve got the first part covered here - we know what angle the second handwill be pointing at in radians. Now we need to work out the coordinates.

Again, let’s keep this as simple as possible and only work with the unitcircle; the circle with a radius of 1. This means that our hands will all havea length of one but, on the bright side, it means that the maths will be easyfor us to swallow.

Write the test first

  1. func TestSecondHandVector(t *testing.T) {
  2. cases := []struct {
  3. time time.Time
  4. point Point
  5. }{
  6. {simpleTime(0, 0, 30), Point{0, -1}},
  7. }
  8. for _, c := range cases {
  9. t.Run(testName(c.time), func(t *testing.T) {
  10. got := secondHandPoint(c.time)
  11. if got != c.point {
  12. t.Fatalf("Wanted %v Point, but got %v", c.point, got)
  13. }
  14. })
  15. }
  16. }

Try to run the test

  1. # github.com/gypsydave5/learn-go-with-tests/math/v4/clockface [github.com/gypsydave5/learn-go-with-tests/math/v4/clockface.test]
  2. ./clockface_test.go:40:11: undefined: secondHandPoint
  3. FAIL github.com/gypsydave5/learn-go-with-tests/math/v4/clockface [build failed]

Write the minimal amount of code for the test to run and check the failing test output

  1. func secondHandPoint(t time.Time) Point {
  2. return Point{}
  3. }
  1. --- FAIL: TestSecondHandPoint (0.00s)
  2. --- FAIL: TestSecondHandPoint/00:00:30 (0.00s)
  3. clockface_test.go:42: Wanted {0 -1} Point, but got {0 0}
  4. FAIL
  5. exit status 1
  6. FAIL github.com/gypsydave5/learn-go-with-tests/math/v4/clockface 0.010s

Write enough code to make it pass

  1. func secondHandPoint(t time.Time) Point {
  2. return Point{0, -1}
  3. }
  1. PASS
  2. ok github.com/gypsydave5/learn-go-with-tests/math/v4/clockface 0.007s

Repeat for new requirements

  1. func TestSecondHandPoint(t *testing.T) {
  2. cases := []struct {
  3. time time.Time
  4. point Point
  5. }{
  6. {simpleTime(0, 0, 30), Point{0, -1}},
  7. {simpleTime(0, 0, 45), Point{-1, 0}},
  8. }
  9. for _, c := range cases {
  10. t.Run(testName(c.time), func(t *testing.T) {
  11. got := secondHandPoint(c.time)
  12. if got != c.point {
  13. t.Fatalf("Wanted %v Point, but got %v", c.point, got)
  14. }
  15. })
  16. }
  17. }

Try to run the test

  1. --- FAIL: TestSecondHandPoint (0.00s)
  2. --- FAIL: TestSecondHandPoint/00:00:45 (0.00s)
  3. clockface_test.go:43: Wanted {-1 0} Point, but got {0 -1}
  4. FAIL
  5. exit status 1
  6. FAIL github.com/gypsydave5/learn-go-with-tests/math/v4/clockface 0.006s

Write enough code to make it pass

Remember our unit circle picture?

picture of the unit circle with the x and y elements of a ray defined as cos(a) and sin(a) respectively, where a is the angle made by the ray with the x axis

We now want the equation that produces X and Y. Let’s write it into seconds:

  1. func secondHandPoint(t time.Time) Point {
  2. angle := secondsInRadians(t)
  3. x := math.Sin(angle)
  4. y := math.Cos(angle)
  5. return Point{x, y}
  6. }

Now we get

  1. --- FAIL: TestSecondHandPoint (0.00s)
  2. --- FAIL: TestSecondHandPoint/00:00:30 (0.00s)
  3. clockface_test.go:43: Wanted {0 -1} Point, but got {1.2246467991473515e-16 -1}
  4. --- FAIL: TestSecondHandPoint/00:00:45 (0.00s)
  5. clockface_test.go:43: Wanted {-1 0} Point, but got {-1 -1.8369701987210272e-16}
  6. FAIL
  7. exit status 1
  8. FAIL github.com/gypsydave5/learn-go-with-tests/math/v4/clockface 0.007s

Wait, what (again)? Looks like we’ve been cursed by the floats once more - bothof those unexpected numbers are infinitessimal - way down at the 16th decimalplace. So again we can either choose to try to increase precision, or to justsay that they’re roughly equal and get on with our lives.

One option to increase the accuracy of these angles would be to use the rationaltype Rat from the math/big package. But given the objective is to draw anSVG and not the moon landings I think we can live with a bit of fuzziness.

  1. func TestSecondHandPoint(t *testing.T) {
  2. cases := []struct {
  3. time time.Time
  4. point Point
  5. }{
  6. {simpleTime(0, 0, 30), Point{0, -1}},
  7. {simpleTime(0, 0, 45), Point{-1, 0}},
  8. }
  9. for _, c := range cases {
  10. t.Run(testName(c.time), func(t *testing.T) {
  11. got := secondHandPoint(c.time)
  12. if !roughlyEqualPoint(got, c.point) {
  13. t.Fatalf("Wanted %v Point, but got %v", c.point, got)
  14. }
  15. })
  16. }
  17. }
  18. func roughlyEqualFloat64(a, b float64) bool {
  19. const equalityThreshold = 1e-7
  20. return math.Abs(a-b) < equalityThreshold
  21. }
  22. func roughlyEqualPoint(a, b Point) bool {
  23. return roughlyEqualFloat64(a.X, b.X) &&
  24. roughlyEqualFloat64(a.Y, b.Y)
  25. }

We’ve defined two functions to define approximate equality between two Points

  • they’ll work if the X and Y elements are within 0.0000001 of each other.That’s still pretty accurate.

and now we get

  1. PASS
  2. ok github.com/gypsydave5/learn-go-with-tests/math/v4/clockface 0.007s

Refactor

I’m still pretty happy with this.

Repeat for new requirements

Well, saying new isn’t enirely accurate - really what we can do now is getthat acceptance test passing! Let’s remind ourselves of what it looks like:

  1. func TestSecondHandAt30Seconds(t *testing.T) {
  2. tm := time.Date(1337, time.January, 1, 0, 0, 30, 0, time.UTC)
  3. want := clockface.Point{X: 150, Y: 150 + 90}
  4. got := clockface.SecondHand(tm)
  5. if got != want {
  6. t.Errorf("Got %v, wanted %v", got, want)
  7. }
  8. }

Try to run the test

  1. --- FAIL: TestSecondHandAt30Seconds (0.00s)
  2. clockface_acceptance_test.go:28: Got {150 60}, wanted {150 240}
  3. FAIL
  4. exit status 1
  5. FAIL github.com/gypsydave5/learn-go-with-tests/math/v5/clockface 0.007s

Write enough code to make it pass

We need to do three things to convert our unit vector into a point on the SVG:

  1. Scale it to the length of the hand
  2. Flip it over the X axis because to account for the SVG having an origin inthe top left hand corner
  3. Translate it to the right position (so that it’s coming from an origin of(150,150))

Fun times!

  1. // SecondHand is the unit vector of the second hand of an analogue clock at time `t`
  2. // represented as a Point.
  3. func SecondHand(t time.Time) Point {
  4. p := secondHandPoint(t)
  5. p = Point{p.X * 90, p.Y * 90} // scale
  6. p = Point{p.X, -p.Y} // flip
  7. p = Point{p.X + 150, p.Y + 150} // translate
  8. return p
  9. }

Scale, flip, and translated in exactly that order. Hooray maths!

  1. PASS
  2. ok github.com/gypsydave5/learn-go-with-tests/math/v5/clockface 0.007s

Refactor

There’s a few magic numbers here that should get pulled out as constants, solet’s do that

  1. const secondHandLength = 90
  2. const clockCentreX = 150
  3. const clockCentreY = 150
  4. // SecondHand is the unit vector of the second hand of an analogue clock at time `t`
  5. // represented as a Point.
  6. func SecondHand(t time.Time) Point {
  7. p := secondHandPoint(t)
  8. p = Point{p.X * secondHandLength, p.Y * secondHandLength}
  9. p = Point{p.X, -p.Y}
  10. p = Point{p.X + clockCentreX, p.Y + clockCentreY} //translate
  11. return p
  12. }

Draw the clock

Well… the second hand anyway…

Let’s do this thing - because there’s nothing worse than not delivering somevalue when it’s just sitting there waiting to get out into the world to dazzlepeople. Let’s draw a second hand!

We’re going to stick a new directory under our main clockface packagedirectory, called (confusingly), clockface. In there we’ll put the mainpackage that will create the binary that will build an SVG:

  1. ├── clockface
  2. └── main.go
  3. ├── clockface.go
  4. ├── clockface_acceptance_test.go
  5. └── clockface_test.go

and inside main.go

  1. package main
  2. import (
  3. "fmt"
  4. "io"
  5. "os"
  6. "time"
  7. "github.com/gypsydave5/learn-go-with-tests/math/v6/clockface"
  8. )
  9. func main() {
  10. t := time.Now()
  11. sh := clockface.SecondHand(t)
  12. io.WriteString(os.Stdout, svgStart)
  13. io.WriteString(os.Stdout, bezel)
  14. io.WriteString(os.Stdout, secondHandTag(sh))
  15. io.WriteString(os.Stdout, svgEnd)
  16. }
  17. func secondHandTag(p clockface.Point) string {
  18. return fmt.Sprintf(`<line x1="150" y1="150" x2="%f" y2="%f" style="fill:none;stroke:#f00;stroke-width:3px;"/>`, p.X, p.Y)
  19. }
  20. const svgStart = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
  21. <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
  22. <svg xmlns="http://www.w3.org/2000/svg"
  23. width="100%"
  24. height="100%"
  25. viewBox="0 0 300 300"
  26. version="2.0">`
  27. const bezel = `<circle cx="150" cy="150" r="100" style="fill:#fff;stroke:#000;stroke-width:5px;"/>`
  28. const svgEnd = `</svg>`

Oh boy am I not trying to win any prizes for beautiful code with this mess -but it does the job. It’s writing an SVG out to os.Stdout - one string ata time.

If we build this

go build

and run it, sending the output into a file

./clockface > clock.svg

We should see something like

a clock with only a second hand

Refactor

This stinks. Well, it doesn’t quite stink stink, but I’m not happy about it.

  1. That whole SecondHand function is super tied to being an SVG… withoutmentioning SVGs or actually producing an SVG…
  2. … while at the same time I’m not testing any of my SVG code.

Yeah, I guess I screwed up. This feels wrong. Let’s try and recover with a moreSVG-centric test.

What are our options? Well, we could try testing that the characters spewing outof the SVGWriter contain things that look like the sort of SVG tag we’reexpecting for a particular time. For instance:

func TestSVGWriterAtMidnight(t *testing.T) {
    tm := time.Date(1337, time.January, 1, 0, 0, 0, 0, time.UTC)

    var b strings.Builder
    clockface.SVGWriter(&b, tm)
    got := b.String()

    want := `<line x1="150" y1="150" x2="150" y2="60"`

    if !strings.Contains(got, want) {
        t.Errorf("Expected to find the second hand %v, in the SVG output %v", want, got)
    }
}

But is this really an improvement?

Not only will it still pass if I don’t produce a valid SVG (as it’s only testingthat a string appears in the output), but it will also fail if I make thesmallest, unimportant change to that string - if I add an extra space betweenthe attributes, for instance.

The biggest smell is really that I’m testing a data structure - XML - by lookingat its representation as a series of characters - as a string. This is never,ever a good idea as it produces problems just like the ones I outline above:a test that’s both too fragile and not sensitive enough. A test that’s testingthe wrong thing!

So the only solution is to test the output as XML. And to do that we’ll needto parse it.

Parsing XML

encoding/xml is the Go package that can handle all things to do withsimple XML parsing.

The function xml.Unmarshall takesa []byte of XML data and a pointer to a struct for it to get unmarshalled into.

So we’ll need a struct to unmarshall our XML into. We could spend some timeworking out what the correct names for all of the nodes and attributes, and howto write the correct structure but, happily, someone has writtenzek a program that will automate all of thathard work for us. Even better, there’s an online version athttps://www.onlinetool.io/xmltogo/. Justpaste the SVG from the top of the file into one box and - bam

  • out pops:
type Svg struct {
    XMLName xml.Name `xml:"svg"`
    Text    string   `xml:",chardata"`
    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  struct {
        Text  string `xml:",chardata"`
        Cx    string `xml:"cx,attr"`
        Cy    string `xml:"cy,attr"`
        R     string `xml:"r,attr"`
        Style string `xml:"style,attr"`
    } `xml:"circle"`
    Line []struct {
        Text  string `xml:",chardata"`
        X1    string `xml:"x1,attr"`
        Y1    string `xml:"y1,attr"`
        X2    string `xml:"x2,attr"`
        Y2    string `xml:"y2,attr"`
        Style string `xml:"style,attr"`
    } `xml:"line"`
}

We could make adjustments to this if we needed to (like changing the name of thestruct to SVG) but it’s definitely good enough to start us off.

func TestSVGWriterAtMidnight(t *testing.T) {
    tm := time.Date(1337, time.January, 1, 0, 0, 0, 0, time.UTC)

    b := bytes.Buffer{}
    clockface.SVGWriter(&b, tm)

    svg := Svg{}
    xml.Unmarshal(b.Bytes(), &svg)

    x2 := "150"
    y2 := "60"

    for _, line := range svg.Line {
        if line.X2 == x2 && line.Y2 == y2 {
            return
        }
    }

    t.Errorf("Expected to find the second hand with x2 of %+v and y2 of %+v, in the SVG output %v", x2, y2, b.String())
}

We write the output of clockface.SVGWriter to a bytes.Bufferand then Unmarshall it into an Svg. We then look at each Line in the Svgto see if any of them have the expected X2 and Y2 values. If we get a matchwe return early (passing the test); if not we fail with a (hopefully)informative message.

# github.com/gypsydave5/learn-go-with-tests/math/v7b/clockface_test [github.com/gypsydave5/learn-go-with-tests/math/v7b/clockface.test]
./clockface_acceptance_test.go:41:2: undefined: clockface.SVGWriter
FAIL    github.com/gypsydave5/learn-go-with-tests/math/v7b/clockface [build failed]

Looks like we’d better write that SVGWriter

package clockface

import (
    "fmt"
    "io"
    "time"
)

const (
    secondHandLength = 90
    clockCentreX     = 150
    clockCentreY     = 150
)

//SVGWriter writes an SVG representation of an analogue clock, showing the time t, to the writer w
func SVGWriter(w io.Writer, t time.Time) {
    io.WriteString(w, svgStart)
    io.WriteString(w, bezel)
    secondHand(w, t)
    io.WriteString(w, svgEnd)
}

func secondHand(w io.Writer, t time.Time) {
    p := secondHandPoint(t)
    p = Point{p.X * secondHandLength, p.Y * secondHandLength} // 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="%.3f" y2="%.3f" style="fill:none;stroke:#f00;stroke-width:3px;"/>`, p.X, p.Y)
}

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>`

The most beautiful SVG writer? No. But hopefully it’ll do the job…

--- FAIL: TestSVGWriterAtMidnight (0.00s)
    clockface_acceptance_test.go:56: Expected to find the second hand with x2 of 150 and y2 of 60, in the SVG output <?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"><circle cx="150" cy="150" r="100" style="fill:#fff;stroke:#000;stroke-width:5px;"/><line x1="150" y1="150" x2="150.000000" y2="60.000000" style="fill:none;stroke:#f00;stroke-width:3px;"/></svg>
FAIL
exit status 1
FAIL    github.com/gypsydave5/learn-go-with-tests/math/v7b/clockface    0.008s

Oooops! The %f format directive is printing our coordinates to the defaultlevel of precision - six decimal places. We should be explicit as to what levelof precision we’re expecting for the coordinates. Let’s say three decimalplaces.

s := fmt.Sprintf(`<line x1="150" y1="150" x2="%.3f" y2="%.3f" style="fill:none;stroke:#f00;stroke-width:3px;"/>`, p.X, p.Y)

And after we update our expectations in the test

    x2 := "150.000"
    y2 := "60.000"

We get:

PASS
ok      github.com/gypsydave5/learn-go-with-tests/math/v7b/clockface    0.006s

We can now shorten our main function:

package main

import (
    "os"
    "time"

    "github.com/gypsydave5/learn-go-with-tests/math/v7b/clockface"
)

func main() {
    t := time.Now()
    clockface.SVGWriter(os.Stdout, t)
}

And we can write a test for another time following the same pattern, but notbefore…

Refactor

Three things stick out:

  1. We’re not really testing for all of the information we need to ensure ispresent - what about the x1 values, for instance?
  2. Also, those attributes for x1 etc. aren’t really strings are they? They’renumbers!
  3. Do I really care about the style of the hand? Or, for that matter, theempty Text node that’s been generated by zak?

We can do better. Let’s make a few adjustments to the Svg struct, and thetests, to sharpen everything up.

type SVG struct {
    XMLName xml.Name `xml:"svg"`
    Xmlns   string   `xml:"xmlns,attr"`
    Width   float64  `xml:"width,attr"`
    Height  float64  `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"`
}

Here I’ve

  • Made the important parts of the struct named types — the Line and theCircle
  • Turned the numeric attributes into float64s instead of strings.
  • Deleted unused attributes like Style and Text
  • Renamed Svg to SVG because it’s the right thing to do.

This will let us assert more precisely on the line we’re looking for:

func TestSVGWriterAtMidnight(t *testing.T) {
    tm := time.Date(1337, time.January, 1, 0, 0, 0, 0, time.UTC)
    b := bytes.Buffer{}

    clockface.SVGWriter(&b, tm)

    svg := SVG{}

    xml.Unmarshal(b.Bytes(), &svg)

    want := Line{150, 150, 150, 60}

    for _, line := range svg.Line {
        if line == want {
            return
        }
    }

    t.Errorf("Expected to find the second hand line %+v, in the SVG lines %+v", want, svg.Line)
}

Finally we can take a leaf out of the unit tests’ tables, and we can writea helper function containsLine(line Line, lines []Line) bool to really makethese tests shine:

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{}
            clockface.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)
            }
        })
    }
}

Now that’s what I call an acceptance test!

Write the test first

So that’s the second hand done. Now let’s get started on the minute hand.

func TestSVGWriterMinutedHand(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{}
            clockface.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)
            }
        })
    }
}

Try to run the test

--- FAIL: TestSVGWriterMinutedHand (0.00s)
    --- FAIL: TestSVGWriterMinutedHand/00:00:00 (0.00s)
        clockface_acceptance_test.go:87: Expected to find the minute hand line {X1:150 Y1:150 X2:150 Y2:70}, in the SVG lines [{X1:150 Y1:150 X2:150 Y2:60}]
FAIL
exit status 1
FAIL    github.com/gypsydave5/learn-go-with-tests/math/v8/clockface    0.007s

We’d better start building some other clockhands, Much in the same way as weproduced the tests for the second hand, we can iterate to produce the followingset of tests. Again we’ll comment out our acceptance test while we get thisworking:

func TestMinutesInRadians(t *testing.T) {
    cases := []struct {
        time  time.Time
        angle float64
    }{
        {simpleTime(0, 30, 0), math.Pi},
    }

    for _, c := range cases {
        t.Run(testName(c.time), func(t *testing.T) {
            got := minutesInRadians(c.time)
            if got != c.angle {
                t.Fatalf("Wanted %v radians, but got %v", c.angle, got)
            }
        })
    }
}

Try to run the test

# github.com/gypsydave5/learn-go-with-tests/math/v8/clockface [github.com/gypsydave5/learn-go-with-tests/math/v8/clockface.test]
./clockface_test.go:59:11: undefined: minutesInRadians
FAIL    github.com/gypsydave5/learn-go-with-tests/math/v8/clockface [build failed]

Write the minimal amount of code for the test to run and check the failing test output

func minutesInRadians(t time.Time) float64 {
    return math.Pi
}

Repeat for new requirements

Well, OK - now let’s make ourselves do some real work. We could model theminute hand as only moving every full minute - so that it ‘jumps’ from 30 to 31minutes past without moving in between. But that would look a bit rubbish. Whatwe want it to do is move a tiny little bit every second.

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 got != c.angle {
                t.Fatalf("Wanted %v radians, but got %v", c.angle, got)
            }
        })
    }
}

How much is that tiny little bit? Well…

  • Sixty seconds in a minute
  • thirty minutes in a half turn of the circle (math.Pi radians)
  • so 30 * 60 seconds in a half turn.
  • So if the time is 7 seconds past the hour …
  • … we’re expecting to see the minute hand at 7 * (math.Pi / (30 * 60))radians past the 12.

Try to run the test

--- FAIL: TestMinutesInRadians (0.00s)
    --- FAIL: TestMinutesInRadians/00:00:07 (0.00s)
        clockface_test.go:62: Wanted 0.012217304763960306 radians, but got 3.141592653589793
FAIL
exit status 1
FAIL    github.com/gypsydave5/learn-go-with-tests/math/v8/clockface    0.009s

Write enough code to make it pass

In the immortal words of Jennifer Aniston: Here comes the sciencebit

func minutesInRadians(t time.Time) float64 {
    return (secondsInRadians(t) / 60) +
        (math.Pi / (30 / float64(t.Minute())))
}

Rather than working out how far to push the minute hand around the clockface forevery second from scratch, here we can just leverage the secondsInRadiansfunction. For every second the minute hand will move 1/60th of the angle thesecond hand moves.

secondsInRadians(t) / 60

Then we just add on the movement for the minutes - similar to the movement ofthe second hand.

math.Pi / (30 / float64(t.Minute()))

And…

PASS
ok      github.com/gypsydave5/learn-go-with-tests/math/v8/clockface    0.007s

Nice and easy.

Repeat for new requirements

Should I add more cases to the minutesInRadians test? At the moment there areonly two. How many cases do I need before I move on to the testing theminuteHandPoint function?

One of my favourite TDD quotes, often attributed to Kent Beck,[^3] is

Write tests until fear is transformed into boredom.

And, frankly, I’m bored of testing that function. I’m confident I know how itworks. So it’s on to the next one.

Write the test first

func TestMinuteHandPoint(t *testing.T) {
    cases := []struct {
        time  time.Time
        point Point
    }{
        {simpleTime(0, 30, 0), Point{0, -1}},
    }

    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)
            }
        })
    }
}

Try to run the test

# github.com/gypsydave5/learn-go-with-tests/math/v9/clockface [github.com/gypsydave5/learn-go-with-tests/math/v9/clockface.test]
./clockface_test.go:79:11: undefined: minuteHandPoint
FAIL    github.com/gypsydave5/learn-go-with-tests/math/v9/clockface [build failed]

Write the minimal amount of code for the test to run and check the failing test output

func minuteHandPoint(t time.Time) Point {
    return Point{}
}
--- FAIL: TestMinuteHandPoint (0.00s)
    --- FAIL: TestMinuteHandPoint/00:30:00 (0.00s)
        clockface_test.go:80: Wanted {0 -1} Point, but got {0 0}
FAIL
exit status 1
FAIL    github.com/gypsydave5/learn-go-with-tests/math/v9/clockface    0.007s

Write enough code to make it pass

func minuteHandPoint(t time.Time) Point {
    return Point{0, -1}
}
PASS
ok      github.com/gypsydave5/learn-go-with-tests/math/v9/clockface    0.007s

Repeat for new requirements

And now for some actual work

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)
            }
        })
    }
}
--- FAIL: TestMinuteHandPoint (0.00s)
    --- FAIL: TestMinuteHandPoint/00:45:00 (0.00s)
        clockface_test.go:81: Wanted {-1 0} Point, but got {0 -1}
FAIL
exit status 1
FAIL    github.com/gypsydave5/learn-go-with-tests/math/v9/clockface    0.007s

Write enough code to make it pass

A quick copy and paste of the secondHandPoint function with some minor changesought to do it…

func minuteHandPoint(t time.Time) Point {
    angle := minutesInRadians(t)
    x := math.Sin(angle)
    y := math.Cos(angle)

    return Point{x, y}
}
PASS
ok      github.com/gypsydave5/learn-go-with-tests/math/v9/clockface    0.009s

Refactor

We’ve definitely got a bit of repetition in the minuteHandPoint andsecondHandPoint - I know because we just copied and pasted one to make theother. Let’s DRY it out with a function.

func angleToPoint(angle float64) Point {
    x := math.Sin(angle)
    y := math.Cos(angle)

    return Point{x, y}
}

and we can rewrite minuteHandPoint and secondHandPoint as one liners:

func minuteHandPoint(t time.Time) Point {
    return angleToPoint(minutesInRadians(t))
}
func secondHandPoint(t time.Time) Point {
    return angleToPoint(secondsInRadians(t))
}
PASS
ok      github.com/gypsydave5/learn-go-with-tests/math/v9/clockface    0.007s

Now we can uncomment the acceptance test and get to work drawing the minute hand

Write enough code to make it pass

Another quick copy-and-paste with some minor adjustments

func minuteHand(w io.Writer, t time.Time) {
    p := minuteHandPoint(t)
    p = Point{p.X * minuteHandLength, p.Y * minuteHandLength}
    p = Point{p.X, -p.Y}
    p = Point{p.X + clockCentreX, p.Y + clockCentreY}
    fmt.Fprintf(w, `<line x1="150" y1="150" x2="%.3f" y2="%.3f" style="fill:none;stroke:#000;stroke-width:3px;"/>`, p.X, p.Y)
}
PASS
ok      github.com/gypsydave5/learn-go-with-tests/math/v9/clockface    0.006s

But the proof of the pudding is in the eating - if we now compile and run ourclockface program, we should see something like

a clock with only a second hand

Refactor

Let’s remove the duplication from the secondHand and minuteHand functions,putting all of that scale, flip and translate logic all in one place.

func secondHand(w io.Writer, t time.Time) {
    p := makeHand(secondHandPoint(t), secondHandLength)
    fmt.Fprintf(w, `<line x1="150" y1="150" x2="%.3f" y2="%.3f" style="fill:none;stroke:#f00;stroke-width:3px;"/>`, p.X, p.Y)
}

func minuteHand(w io.Writer, t time.Time) {
    p := makeHand(minuteHandPoint(t), minuteHandLength)
    fmt.Fprintf(w, `<line x1="150" y1="150" x2="%.3f" y2="%.3f" style="fill:none;stroke:#000;stroke-width:3px;"/>`, p.X, p.Y)
}

func makeHand(p Point, length float64) Point {
    p = Point{p.X * length, p.Y * length}
    p = Point{p.X, -p.Y}
    return Point{p.X + clockCentreX, p.Y + clockCentreY}
}
PASS
ok      github.com/gypsydave5/learn-go-with-tests/math/v9/clockface    0.007s

There… now it’s just the hour hand to do!

Write the test first

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{}
            clockface.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)
            }
        })
    }
}

Try to run the test

--- FAIL: TestSVGWriterHourHand (0.00s)
    --- FAIL: TestSVGWriterHourHand/06:00:00 (0.00s)
        clockface_acceptance_test.go:113: Expected to find the minute hand line {X1:150 Y1:150 X2:150 Y2:200}, in the SVG lines [{X1:150 Y1:150 X2:150 Y2:60} {X1:150 Y1:150 X2:150 Y2:70}]
FAIL
exit status 1
FAIL    github.com/gypsydave5/learn-go-with-tests/math/v10/clockface    0.013s

Again, let’s comment this one out until we’ve got the some coverage with thelower level tests:

Write the test first

func TestHoursInRadians(t *testing.T) {
    cases := []struct {
        time  time.Time
        angle float64
    }{
        {simpleTime(6, 0, 0), math.Pi},
    }

    for _, c := range cases {
        t.Run(testName(c.time), func(t *testing.T) {
            got := hoursInRadians(c.time)
            if got != c.angle {
                t.Fatalf("Wanted %v radians, but got %v", c.angle, got)
            }
        })
    }
}

Try to run the test

# github.com/gypsydave5/learn-go-with-tests/math/v10/clockface [github.com/gypsydave5/learn-go-with-tests/math/v10/clockface.test]
./clockface_test.go:97:11: undefined: hoursInRadians
FAIL    github.com/gypsydave5/learn-go-with-tests/math/v10/clockface [build failed]

Write the minimal amount of code for the test to run and check the failing test output

func hoursInRadians(t time.Time) float64 {
    return math.Pi
}
PASS
ok      github.com/gypsydave5/learn-go-with-tests/math/v10/clockface    0.007s

Repeat for new requirements

func TestHoursInRadians(t *testing.T) {
    cases := []struct {
        time  time.Time
        angle float64
    }{
        {simpleTime(6, 0, 0), math.Pi},
        {simpleTime(0, 0, 0), 0},
    }

    for _, c := range cases {
        t.Run(testName(c.time), func(t *testing.T) {
            got := hoursInRadians(c.time)
            if got != c.angle {
                t.Fatalf("Wanted %v radians, but got %v", c.angle, got)
            }
        })
    }
}

Try to run the test

--- FAIL: TestHoursInRadians (0.00s)
    --- FAIL: TestHoursInRadians/00:00:00 (0.00s)
        clockface_test.go:100: Wanted 0 radians, but got 3.141592653589793
FAIL
exit status 1
FAIL    github.com/gypsydave5/learn-go-with-tests/math/v10/clockface    0.007s

Write enough code to make it pass

func hoursInRadians(t time.Time) float64 {
    return (math.Pi / (6 / float64(t.Hour())))
}

Repeat for new requirements

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},
    }

    for _, c := range cases {
        t.Run(testName(c.time), func(t *testing.T) {
            got := hoursInRadians(c.time)
            if got != c.angle {
                t.Fatalf("Wanted %v radians, but got %v", c.angle, got)
            }
        })
    }
}

Try to run the test

--- FAIL: TestHoursInRadians (0.00s)
    --- FAIL: TestHoursInRadians/21:00:00 (0.00s)
        clockface_test.go:101: Wanted 4.71238898038469 radians, but got 10.995574287564276
FAIL
exit status 1
FAIL    github.com/gypsydave5/learn-go-with-tests/math/v10/clockface    0.014s

Write enough code to make it pass

func hoursInRadians(t time.Time) float64 {
    return (math.Pi / (6 / (float64(t.Hour() % 12))))
}

Remember, this is not a 24 hour clock; we have to use the remainder operator toget the remainder of the current hour divided by 12.

PASS
ok      github.com/gypsydave5/learn-go-with-tests/math/v10/clockface    0.008s

Write the test first

Now let’s try and move the hour hand around the clockface based on the minutesand the seconds that have passed.

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 got != c.angle {
                t.Fatalf("Wanted %v radians, but got %v", c.angle, got)
            }
        })
    }
}

Try to run the test

--- FAIL: TestHoursInRadians (0.00s)
    --- FAIL: TestHoursInRadians/00:01:30 (0.00s)
        clockface_test.go:102: Wanted 0.013089969389957472 radians, but got 0
FAIL
exit status 1
FAIL    github.com/gypsydave5/learn-go-with-tests/math/v10/clockface    0.007s

Write enough code to make it pass

Again, a bit of thinking is now required. We need to move the hour hand alonga little bit for both the minutes and the seconds. Luckily we have an anglealready to hand for the minutes and the seconds - the one returned byminutesInRadians. We can reuse it!

So the only question is by what factor to reduce the size of that angle. Onefull turn is one hour for the minute hand, but for the hour hand it’s twelvehours. So we just divide the angle returned by minutesInRadians by twelve:

func hoursInRadians(t time.Time) float64 {
    return (minutesInRadians(t) / 12) +
        (math.Pi / (6 / float64(t.Hour()%12)))
}

and behold:

--- FAIL: TestHoursInRadians (0.00s)
    --- FAIL: TestHoursInRadians/00:01:30 (0.00s)
        clockface_test.go:104: Wanted 0.013089969389957472 radians, but got 0.01308996938995747
FAIL
exit status 1
FAIL    github.com/gypsydave5/learn-go-with-tests/math/v10/clockface    0.007s

AAAAARGH BLOODY FLOATING POINT ARITHMETIC!

Let’s update our test to use roughlyEqualFloat64 for the comparison of theangles.

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(got, c.angle) {
                t.Fatalf("Wanted %v radians, but got %v", c.angle, got)
            }
        })
    }
}
PASS
ok      github.com/gypsydave5/learn-go-with-tests/math/v10/clockface    0.007s

Refactor

If we’re going to use roughlyEqualFloat64 in one of our radians tests, weshould probably use it for all of them. That’s a nice and simple refactor.

Hour Hand Point

Right, it’s time to calculate where the hour hand point is going to go byworking out the unit vector.

Write the test first

func TestHourHandPoint(t *testing.T) {
    cases := []struct {
        time  time.Time
        point Point
    }{
        {simpleTime(6, 0, 0), Point{0, -1}},
        {simpleTime(21, 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)
            }
        })
    }
}

Wait, am I just going to throw two test cases out there at once? Isn’t this bad TDD?

On TDD Zealotry

Test driven development is not a religion. Some people mightact like it is - usually people who don’t do TDD but who are happy to moanon Twitter or Dev.to that it’s only done by zealots and that they’re ‘beingpragmatic’ when they don’t write tests. But it’s not a religion. It’s tool.

I know what the two tests are going to be - I’ve tested two other clock handsin exactly the same way - and I already know what my implementation is going tobe - I wrote a function for the general case of changing an angle into a pointin the minute hand iteration.

I’m not going to plough through TDD ceremony for the sake of it. Tests area tool to help me write better code. TDD is a technique to help me write bettercode. Neither tests nor TDD are an end in themselves.

My confidence has increased, so I feel I can make larger strides forward. I’mgoing to ‘skip’ a few steps, because I know where I am, I know where I’m goingand I’ve been down this road before.

But also note: I’m not skipping writing the tests entirely.

Try to run the test

# github.com/gypsydave5/learn-go-with-tests/math/v11/clockface [github.com/gypsydave5/learn-go-with-tests/math/v11/clockface.test]
./clockface_test.go:119:11: undefined: hourHandPoint
FAIL    github.com/gypsydave5/learn-go-with-tests/math/v11/clockface [build failed]

Write enough code to make it pass

func hourHandPoint(t time.Time) Point {
    return angleToPoint(hoursInRadians(t))
}

As I said, I know where I am and I know where I’m going. Why pretend otherwise?The tests will soon tell me if I’m wrong.

PASS
ok      github.com/gypsydave5/learn-go-with-tests/math/v11/clockface    0.009s

Draw the hour hand

And finally we get to draw in the hour hand. We can bring in that acceptancetest by uncommenting it:

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{}
            clockface.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)
            }
        })
    }
}

Try to run the test

--- FAIL: TestSVGWriterHourHand (0.00s)
    --- FAIL: TestSVGWriterHourHand/06:00:00 (0.00s)
        clockface_acceptance_test.go:113: Expected to find the minute hand line {X1:150 Y1:150 X2:150 Y2:200}, in the SVG lines [{X1:150 Y1:150 X2:150 Y2:60} {X1:150 Y1:150 X2:150 Y2:70}]
FAIL
exit status 1
FAIL    github.com/gypsydave5/learn-go-with-tests/math/v10/clockface    0.013s

Write enough code to make it pass

And we can now make our final adjustments to svgWriter.go

const (
    secondHandLength = 90
    minuteHandLength = 80
    hourHandLength   = 50
    clockCentreX     = 150
    clockCentreY     = 150
)

//SVGWriter writes an SVG representation of an analogue clock, showing the time t, to the writer w
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)
}

// ...

func hourHand(w io.Writer, t time.Time) {
    p := makeHand(hourHandPoint(t), hourHandLength)
    fmt.Fprintf(w, `<line x1="150" y1="150" x2="%.3f" y2="%.3f" style="fill:none;stroke:#000;stroke-width:3px;"/>`, p.X, p.Y)
}

and so…

PASS
ok      github.com/gypsydave5/learn-go-with-tests/math/v12/clockface    0.007s

Let’s just check by compiling and running our clockface program.

a clock

Refactor

Looking at clockface.go, there are a few ‘magic numbers’ floating about. Theyare all based around how many hours/minutes/seconds there are in a half-turnaround a clockface. Let’s refactor so that we make explicit their meaning.

const (
    secondsInHalfClock = 30
    secondsInClock     = 2 * secondsInHalfClock
    minutesInHalfClock = 30
    minutesInClock     = 2 * minutesInHalfClock
    hoursInHalfClock   = 6
    hoursInClock       = 2 * hoursInHalfClock
)

Why do this? Well, it makes explicit what each number means in the equation.If - when - we come back to this code, these names will help us to understandwhat’s going on.

Moreover, should we ever want to make some really, really WEIRD clocks - oneswith 4 hours for the hour hand, and 20 seconds for the second hand say - theseconstants could easily become parameters. We’re helping to leave that door open(even if we never go through it).

Wrapping up

Do we need to do anything else?

First, let’s pat ourselves on the back - we’ve written a program that makes anSVG clockface. It works and it’s great. It will only ever make one sort ofclockface - but that’s fine! Maybe you only want one sort of clockface.There’s nothing wrong with a program that solves a specific problem and nothingelse.

A Program… and a Library

But the code we’ve written does solve a more general set of problems to dowith drawing a clockface. Because we used tests to think about each small partof the problem in isolation, and because we codified that isolation withfunctions, we’ve built a very reasonable little API for clockface calculations.

We can work on this project and turn it into something more general - a libraryfor calculating clockface angles and/or vectors.

In fact, providing the library along with the program is a really good idea.It costs us nothing, while increasing the utility of our program and helping todocument how it works.

APIs should come with programs, and vice versa. An API that you must write Ccode to use, which cannot be invoked easily from the command line, is harder tolearn and use. And contrariwise, it’s a royal pain to have interfaces whoseonly open, documented form is a program, so you cannot invoke them easily froma C program. — Henry Spencer, in The Art of Unix Programming

In my final take on this program, I’ve made theunexported functions within clockface into a public API for the library, withfunctions to calculate the angle and unit vector for each of the clock hands.I’ve also split the SVG generation part into its own package, svg, which isthen used by the clockface program directly. Naturally I’ve documented each ofthe functions and packages.

Talking about SVGs…

The Most Valuable Test

I’m sure you’ve noticed that the most sophisticated piece of code for handlingSVGs isn’t in our application code at all; it’s in the test code. Should thismake us feel uncomfortable? Shouldn’t we do something like

  • use a template from text/template?
  • use an XML library (much as we’re doing in our test)?
  • use an SVG library?

We could refactor our code to do any of these things, and we can do so becausebecause it doesn’t matter how we produce our SVG, what’s important is thatit’s an SVG that we produce. As such, the part of our system that needs to knowthe most about SVGs - that needs to be the strictest about what constitutes anSVG - is the test for the SVG output; it needs to have enough context andknowledge about SVGs for us to be confident that we’re outputting an SVG.

We may have felt odd that we were pouring a lot of time and effort into thoseSVG tests - importing an XML library, parsing XML, refactoring the structs - butthat test code is a valuable part of our codebase - possibly more valuable thanthe current production code. It will help guarantee that the output is alwaysa valid SVG, no matter what we choose to use to produce it.

Tests are not second class citizens - they are not ‘throwaway’ code. Good testswill last a lot longer than the particular version of the code they aretesting. You should never feel like you’re spending ‘too much time’ writing yourtests. It’s usually a wise investment.

[^1]: This is a lot easier than writing a name out by hand as a string and then having to keep it in sync with the actual time. Believe me you don’t want to do that…

[^2]: In short it makes it easier to do calculus with circles as π just keeps coming up as an angle if you use normal degrees, so if you count your angles in πs it makes all the equations simpler.

[^3]: Missattributed because, like all great authors, Kent Beck is more quoted than read. Beck himself attributes it to Phlip.