Wiskunde
Je kunt hier alle code van dit hoofdstuk vinden
Ondanks alle kracht van moderne computers om razendsnel enorme sommen te berekenen, gebruikt de gemiddelde ontwikkelaar zelden wiskunde om zijn werk te doen. Maar vandaag zal anders zijn! Vandaag gebruiken we wiskunde om een echt probleem op te lossen. En geen saaie wiskunde, we gaan goniometrie, vectoren en allerlei andere dingen gebruiken waarvan je altijd zei dat je ze na de middelbare school nooit meer zou hoeven gebruiken.
Het probleem
Je wilt een SVG maken van een klok. Geen digitale klok, dat zou veel te makkelijk zijn, maar een analoge klok, met wijzers. Je zoekt niets bijzonders, gewoon een handige functie die een tijd uit het Time pakket haalt en een SVG van een klok genereert met alle wijzers: uur, minuut en seconde, die in de juiste richting wijzen. Hoe moeilijk kan dat zijn?
Eerst hebben we een SVG van een klok nodig om mee te spelen. SVG's zijn een fantastisch afbeeldingsformaat om programmatisch te bewerken, omdat ze geschreven zijn als een reeks vormen, beschreven in XML. Dus deze klok:
is als volgt beschreven:
<?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">
<!-- bezel -->
<circle cx="150" cy="150" r="100" style="fill:#fff;stroke:#000;stroke-width:5px;"/>
<!-- hour hand -->
<line x1="150" y1="150" x2="114.150000" y2="132.260000"
style="fill:none;stroke:#000;stroke-width:7px;"/>
<!-- minute hand -->
<line x1="150" y1="150" x2="101.290000" y2="99.730000"
style="fill:none;stroke:#000;stroke-width:7px;"/>
<!-- second hand -->
<line x1="150" y1="150" x2="77.190000" y2="202.900000"
style="fill:none;stroke:#f00;stroke-width:3px;"/>
</svg>Het is een cirkel met drie lijnen. Elke lijn begint in het midden van de cirkel (x=150, y=150) en eindigt op enige afstand.
Wat we dus gaan doen is het bovenstaande op een of andere manier reconstrueren, maar dan veranderen we de lijnen zodat ze in de juiste richting wijzen voor een bepaalde tijd.
Een acceptatie test
Voordat we er te diep op ingaan, laten we eerst eens nadenken over een acceptatietest.
Wacht even, je weet nog niet wat een acceptatietest is. Laat me het proberen uit te leggen.
Laat me je eens het volgende vragen: hoe ziet winnen eruit? Hoe weten we dat we klaar zijn met werken? TDD biedt een goede manier om te weten wanneer je klaar bent: wanneer de test slaagt. Soms is het fijn, eigenlijk bijna altijd, om een test te schrijven die aangeeft wanneer je klaar bent met het schrijven van de hele bruikbare feature. Niet alleen een test die aangeeft dat een bepaalde functie werkt zoals je verwacht, maar een test die aangeeft dat alles wat je probeert te bereiken, de 'feature'. voltooid is.
Deze tests worden soms 'acceptatietests' of 'feature tests' genoemd. Het idee is dat je een test op hoog niveau schrijft om te beschrijven wat je probeert te bereiken: een gebruiker klikt bijvoorbeeld op een knop op een website en ziet een complete lijst met de gevangen Pokémon. Zodra we die test hebben geschreven, kunnen we meer tests schrijven, de unit tests, die toewerken naar een werkend systeem dat de acceptatietest zal doorstaan. In ons voorbeeld kunnen deze tests bijvoorbeeld gaan over het weergeven van een webpagina met een knop, het testen van route handlers op een webserver, het uitvoeren van database-opzoekingen, enzovoort. Al deze dingen worden tests volgens de TDD-aanpak en dragen allemaal bij aan het slagen van de oorspronkelijke acceptatietest.
Iets als deze klassieke afbeelding van Nat Pryce en Steve Freeman

Hoe dan ook, laten we proberen die acceptatietest te schrijven, de test die ons laat weten wanneer we klaar zijn.
We hebben een voorbeeldklok, dus laten we eens nadenken over de belangrijke parameters.
<line x1="150" y1="150" x2="114.150000" y2="132.260000"
style="fill:none;stroke:#000;stroke-width:7px;"/>Het middelpunt van de klok (de attributen x1 en y1 voor deze lijn) is voor elke wijzer hetzelfde. De getallen die voor elke wijzer moeten veranderen, de parameters voor de SVG, zijn de attributen x2 en y2. We hebben een X en een Y nodig voor elke wijzer van de klok.
Ik zou aan meer parameters kunnen denken, de straal van de cirkel van de wijzerplaat, de grootte van de SVG, de kleuren van de wijzers, hun vorm, etc. Maar het is beter om te beginnen met het oplossen van een eenvoudig, concreet probleem met een eenvoudige, concrete oplossing, en dan parameters toe te voegen om de oplossing te generaliseren.
Laten we dus van het volgende uitgaan:
iedere klok heeft een middelpunt op (150, 150)
de uren wijzer is 50 lang
de minutenwijzer is 80 lang
de secondenwijzer is 90 lang.
Let op: SVG's hebben een punt nodig: de oorsprong, punt (0,0), bevindt zich in de linkerbovenhoek, niet linksonder zoals we misschien zouden verwachten. Het is belangrijk om dit te onthouden wanneer we bepalen waar we welke getallen in onze lijnen moeten invoeren.
Tot slot bepaal ik nog niet hoe we de SVG gaan maken, we zouden een sjabloon uit het text/template pakket kunnen gebruiken, of we zouden bytes gewoon naar een bytes.Buffer of een andere writer kunnen sturen. Maar we weten dat we die getallen nodig hebben, dus laten we ons concentreren op het testen van iets dat ze genereert.
Schrijf eerst je test
Mijn eerste test ziet er dus zo uit:
package clockface_test
import (
"projectpath/clockface"
"testing"
"time"
)
func TestSecondHandAtMidnight(t *testing.T) {
tm := time.Date(1337, time.January, 1, 0, 0, 0, 0, time.UTC)
want := clockface.Point{X: 150, Y: 150 - 90}
got := clockface.SecondHand(tm)
if got != want {
t.Errorf("Got %v, wanted %v", got, want)
}
}Weet je nog hoe SVG's hun coördinaten vanuit de linkerbovenhoek weergeven? Om de secondewijzer op middernacht te plaatsen, verwachten we dat deze niet van het midden van de wijzerplaat op de X-as is verplaatst, nog steeds 150, en dat de Y-as de lengte van de wijzer 'omhoog' vanaf het midden is; 150 min 90.
Probeer de test uit te voeren
Hiermee worden de verwachte fouten rondom de ontbrekende functies en typen verholpen:
--- FAIL: TestSecondHandAtMidnight (0.00s)
./clockface_test.go:13:10: undefined: clockface.Point
./clockface_test.go:14:9: undefined: clockface.SecondHandDus een Point waar de punt van de secondewijzer moet komen, en een functie om dat te krijgen.
Schrijf de minimale hoeveelheid code om de test te laten uitvoeren en de falende test output te controleren
Laten we deze typen implementeren om de code te laten compileren
package clockface
import "time"
// A Point represents a two-dimensional Cartesian coordinate
type Point struct {
X float64
Y float64
}
// SecondHand is the unit vector of the second hand of an analogue clock at time `t`
// represented as a Point.
func SecondHand(t time.Time) Point {
return Point{}
}en nu krijgen we:
--- FAIL: TestSecondHandAtMidnight (0.00s)
clockface_test.go:17: Got {0 0}, wanted {150 60}
FAIL
exit status 1
FAIL learn-go-with-tests/math/clockface 0.006sSchrijf genoeg code om de test te laten slagen
Wanneer we de verwachte fout krijgen, kunnen we de retourwaarde invullen van SecondHand:
// SecondHand is the unit vector of the second hand of an analogue clock at time `t`
// represented as a Point.
func SecondHand(t time.Time) Point {
return Point{150, 60}
}Ziehier, een geslaagd examen.
PASS
ok clockface 0.006sRefactor
Geen reden te refactoren, er is nauwelijks genoeg code!
Herhaal voor nieuwe eisen
Waarschijnlijk moeten we hier wat werk doen dat niet alleen bestaat uit het terugzetten van een klok die op elk tijdstip middernacht aangeeft...
Schrijf eerst je test
func TestSecondHandAt30Seconds(t *testing.T) {
tm := time.Date(1337, time.January, 1, 0, 0, 30, 0, time.UTC)
want := clockface.Point{X: 150, Y: 150 + 90}
got := clockface.SecondHand(tm)
if got != want {
t.Errorf("Got %v, wanted %v", got, want)
}
}Hetzelfde idee, maar nu wijst de secondewijzer naar beneden. Daarom voegen we de lengte toe aan de Y-as.
Dit zal compileren... maar hoe zorgen we ervoor dat het lukt?
Denken in tijd
Hoe gaan we dit probleem oplossen?
Elke minuut doorloopt de secondewijzer dezelfde 60 standen en wijst hij in 60 verschillende richtingen. Bij 0 seconden wijst hij naar de bovenkant van de wijzerplaat, bij 30 seconden naar de onderkant. Zo simpel is het.
Dus als ik wilde nadenken over de richting waarin de secondewijzer wees, bijvoorbeeld 37 seconden, zou ik de hoek tussen 12 uur en 37/60e van een volledige rotatie willen. In graden is dit (360/60) * 37 = 222, maar het is makkelijker om gewoon te onthouden dat het 37/60e van een volledige rotatie is.
Maar de hoek is slechts het halve verhaal; we moeten de X- en Y-coördinaat weten waar de punt van de secondewijzer naar wijst. Hoe kunnen we dat berekenen?
Wiskunde
Stel je een cirkel voor met een straal van 1, getekend rond de oorsprong, de coördinaat 0, 0.

Dit wordt de 'eenheidscirkel' genoemd, omdat... de straal 1 eenheid is!
De omtrek van de cirkel bestaat uit punten op het raster, of coördinaten. De x- en y-componenten van elk van deze coördinaten vormen een driehoek, waarvan de schuine zijde altijd 1 is (d.w.z. de straal van de cirkel).

Met trigonometrie kunnen we nu de lengtes van X en Y voor elke driehoek berekenen, mits we de hoek kennen die ze met de oorsprong maken. De X-coördinaat is dan cos(a) en de Y-coördinaat sin(a), waarbij a de hoek is tussen de lijn en de (positieve) x-as.

(Als je dit niet gelooft, neem dan een kijkje op Wikipedia...)
Nog een laatste verandering: omdat we de hoek vanaf 12 uur willen meten in plaats van vanaf de X-as (3 uur), moeten we de assen omdraaien; nu is x = sin(a) en y = cos(a).

Nu weten we hoe we de hoek van de secondewijzer (1/60e van een cirkel voor elke seconde) en de X- en Y-coördinaten kunnen bepalen. We hebben functies nodig voor zowel sin als cos.
math
mathGelukkig heeft het Go-mathpakket beide, met één klein minpuntje waar we even aan moeten wennen. Kijk maar naar de beschrijving van math.Cos:
Cos retourneert de cosinus van het radiant argument x.
De hoek moet in radialen zijn. Dus wat is een radiaal? In plaats van de volledige omwenteling van een cirkel te definiëren als 360 graden, definiëren we een volledige omwenteling als 2π radialen. Er zijn goede redenen om dit te doen, maar daar gaan we nu niet op in.
Nu we wat gelezen, geleerd en nagedacht hebben, kunnen we de volgende toets schrijven.
Schrijf eerst je test
Al deze wiskunde is moeilijk en verwarrend. Ik weet niet zeker of ik begrijp wat er aan de hand is, dus laten we een test maken! We hoeven de hele opgave niet in één keer op te lossen, laten we beginnen met het berekenen van de juiste hoek, in radialen, voor de secondewijzer op een bepaald moment.
Ik ga de acceptatietest waar ik aan werkte, even buiten beschouwing laten terwijl ik aan deze tests werk. Ik wil niet afgeleid worden door die test terwijl ik deze test laat slagen.
Een samenvatting van packages
Onze acceptatie tests bevinden zich momenteel in het clockface_test-pakket. Onze tests kunnen ook buiten het clockface-pakket worden uitgevoerd, zolang hun naam eindigt op _test.go.
Ik ga deze radialentests schrijven binnen het clockface-pakket; ze worden mogelijk nooit geëxporteerd en mogelijk verwijderd (of verplaatst) zodra ik meer inzicht heb in wat er echt gebeurt. Ik hernoem mijn acceptatie test bestand naar clockface_acceptance_test.go, zodat ik een nieuw bestand met de naam clockface_test kan maken om seconden in radialen te testen.
package clockface
import (
"math"
"testing"
"time"
)
func TestSecondsInRadians(t *testing.T) {
thirtySeconds := time.Date(312, time.October, 28, 0, 0, 30, 0, time.UTC)
want := math.Pi
got := secondsInRadians(thirtySeconds)
if want != got {
t.Fatalf("Wanted %v radians, but got %v", want, got)
}
}Hier testen we dat 30 seconden over de minuut de secondewijzer halverwege de klok zou moeten zetten. En dit is ons eerste gebruik van het mathpakket! Als een volledige omwenteling van een cirkel 2π radialen is, weten we dat halverwege gewoon π radialen zou moeten zijn. math.Pi geeft ons een waarde voor π.
Probeer de test uit te voeren
./clockface_test.go:12:9: undefined: secondsInRadiansSchrijf de minimale hoeveelheid code om de test te laten uitvoeren en de falende test output te controleren
func secondsInRadians(t time.Time) float64 {
return 0
}clockface_test.go:15: Wanted 3.141592653589793 radians, but got 0Schrijf genoeg code om de test te laten slagen
func secondsInRadians(t time.Time) float64 {
return math.Pi
}PASS
ok clockface 0.011sRefactor
Er hoeft nog niets gerefactored te worden
Herhaal voor nieuwe eisen
Nu kunnen we de test uitbreiden met een paar extra scenario's. Ik 'spoel' even vooruit en laat wat reeds gerefactoriseerde testcode zien, het zou duidelijk genoeg moeten zijn hoe ik op mijn gewenste punt ben gekomen.
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 got != c.angle {
t.Fatalf("Wanted %v radians, but got %v", c.angle, got)
}
})
}
}Ik heb een paar hulpfuncties toegevoegd om het schrijven van deze tabel gebaseerde test wat minder saai te maken. testName converteert een tijd naar een digitaal horlogeformaat (UU:MM:SS), en simpleTime construeert een time.Time met alleen de onderdelen die we echt belangrijk vinden (opnieuw uren, minuten en seconden). Hier zijn ze:
func simpleTime(hours, minutes, seconds int) time.Time {
return time.Date(312, time.October, 28, hours, minutes, seconds, 0, time.UTC)
}
func testName(t time.Time) string {
return t.Format("15:04:05")
}Deze twee functies moeten het schrijven en onderhouden van deze tests (en toekomstige tests) een stuk eenvoudiger maken.
Dit levert ons een mooi testresultaat op:
clockface_test.go:24: Wanted 0 radians, but got 3.141592653589793
clockface_test.go:24: Wanted 4.71238898038469 radians, but got 3.141592653589793Tijd om alle wiskundige zaken waar we het hierboven over hadden in de praktijk te brengen:
func secondsInRadians(t time.Time) float64 {
return float64(t.Second()) * (math.Pi / 30)
}Wacht even is (2π / 60) radialen... streep de 2 weg en we krijgen π/30 radialen. Vermenigvuldig dat met het aantal seconden (als een float64) en dan zouden alle tests moeten slagen...
clockface_test.go:24: Wanted 3.141592653589793 radians, but got 3.1415926535897936Wacht even, wat gebeurt hier?
Floats zijn verschikkelijk
Rekenen met komma getallen is notoir onnauwkeurig. Computers kunnen eigenlijk alleen gehele getallen verwerken, en tot op zekere hoogte ook rationale getallen. Decimale getallen worden onnauwkeurig, vooral wanneer we ze ontbinden in factoren, zoals in de functie secondenInRadians. Door math.Pi te delen door 30 en vervolgens te vermenigvuldigen met 30, hebben we een getal gekregen dat niet langer hetzelfde is als math.Pi.
Er zijn twee manieren om hiermee om te gaan:
Leer ermee leven
Herschrijf onze functie door onze vergelijking te herstructureren
Nu lijkt (1) misschien niet zo aantrekkelijk, maar het is vaak de enige manier om komma-gelijkheid te laten werken. Een onnauwkeurigheid van een oneindig klein deel maakt eerlijk gezegd niet uit voor het tekenen van een wijzerplaat, dus we zouden een functie kunnen schrijven die een 'voldoende nauwkeurige' gelijkheid definieert voor onze hoeken. Maar er is een eenvoudige manier om de nauwkeurigheid terug te krijgen: we herschikken de vergelijking zodat we niet langer hoeven te delen en vervolgens te vermenigvuldigen. We kunnen het allemaal doen door simpelweg te delen.
Dus in plaats van
numberOfSeconds * π / 30kunnen we het volgende schrijven
π / (30 / numberOfSeconds)wat eigenlijk precies hetzelfde doet.
In Go:
func secondsInRadians(t time.Time) float64 {
return (math.Pi / (30 / (float64(t.Second()))))
}En alle testen slagen
PASS
ok clockface 0.005sJe code zou er nu ongeveer zo uit moeten zien.
Een opmerking over delen door nul
Computers vinden delen door nul vaak niet prettig, omdat oneindigheid een beetje vreemd is.
Als je in Go expliciet door nul probeert te delen, krijg je een compilatiefout.
package main
import (
"fmt"
)
func main() {
fmt.Println(10.0 / 0.0) // fails to compile
}Uiteraard kan de compiler niet altijd voorspellen dat je door nul deelt, zoals bij onze t.Second()
Probeer dit maar eens:
func main() {
fmt.Println(10.0 / zero())
}
func zero() float64 {
return 0.0
}Dit geeft +Inf (oneindig) terug. Delen door +Inf lijkt nul te zijn, en dat zien we terug in het volgende:
package main
import (
"fmt"
"math"
)
func main() {
fmt.Println(secondsinradians())
}
func zero() float64 {
return 0.0
}
func secondsinradians() float64 {
return (math.Pi / (30 / (float64(zero()))))
}Herhaal voor de nieuwe eisen
We hebben het eerste deel dus al behandeld: we weten in welke hoek de secondewijzer in radialen zal wijzen. Nu moeten we de coördinaten bepalen.
Laten we het zo simpel mogelijk houden en alleen met de eenheidscirkel werken; de cirkel met een straal van 1. Dit betekent dat al onze wijzers een lengte van 1 hebben, maar aan de andere kant betekent dit dat de berekening voor ons makkelijker te begrijpen is.
Schrijf eerst je test
func TestSecondHandPoint(t *testing.T) {
cases := []struct {
time time.Time
point Point
}{
{simpleTime(0, 0, 30), Point{0, -1}},
}
for _, c := range cases {
t.Run(testName(c.time), func(t *testing.T) {
got := secondHandPoint(c.time)
if got != c.point {
t.Fatalf("Wanted %v Point, but got %v", c.point, got)
}
})
}
}Probeer de test uit te voeren
./clockface_test.go:40:11: undefined: secondHandPointSchrijf de minimale hoeveelheid code om de test te laten uitvoeren en de falende test output te controleren
func secondHandPoint(t time.Time) Point {
return Point{}
}clockface_test.go:42: Wanted {0 -1} Point, but got {0 0}Schrijf genoeg code om de test te laten slagen
func secondHandPoint(t time.Time) Point {
return Point{0, -1}
}PASS
ok clockface 0.007sHerhaal voor nieuwe eisen
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 got != c.point {
t.Fatalf("Wanted %v Point, but got %v", c.point, got)
}
})
}
}Probeer de test uit te voeren
clockface_test.go:43: Wanted {-1 0} Point, but got {0 -1}Schrijf genoeg code om de test te laten slagen
Herinner je zich de afbeelding met de eenheidscirkel nog?

Bedenk ook dat we de hoek willen meten vanaf 12 uur, de Y-as, in plaats van vanaf de X-as. We willen de hoek meten tussen de secondewijzer en 3 uur.

We willen nu de vergelijking die X en Y oplevert. Laten we deze in seconden opschrijven:
func secondHandPoint(t time.Time) Point {
angle := secondsInRadians(t)
x := math.Sin(angle)
y := math.Cos(angle)
return Point{x, y}
}Nu krijgen we
clockface_test.go:43: Wanted {0 -1} Point, but got {1.2246467991473515e-16 -1}
clockface_test.go:43: Wanted {-1 0} Point, but got {-1 -1.8369701987210272e-16}Wacht, wat (alweer)? Het lijkt erop dat we weer vervloekt zijn door de floats, beide onverwachte getallen zijn infinitesimaal, tot ver in de 16e decimaal. Dus we kunnen er weer voor kiezen om de precisie te verhogen, of gewoon te zeggen dat ze ongeveer gelijk zijn en verder te gaan met ons leven.
Een optie om de nauwkeurigheid van deze hoeken te vergroten, zou zijn om het rationale type Rat uit het math/big-pakket te gebruiken. Maar aangezien het doel is om een SVG te tekenen en niet om op de maan te landen, denk ik dat we wel met wat vaagheid kunnen leven.
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 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)
}We hebben twee functies gedefinieerd om de geschatte gelijkheid tussen twee Points te definiëren. Ze werken als de X- en Y-elementen binnen 0,0000001 van elkaar liggen. Dat is nog steeds behoorlijk nauwkeurig.
En nu krijgen we:
PASS
ok clockface 0.007sRefactor
Ik ben nog steeds behoorlijk blij met de code.
Hier is hoe het eruit ziet op dit moment
Herhaal voor nieuwe eisen
Nou, nieuw zeggen is niet helemaal waar, wat we nu echt kunnen doen is die acceptatietest laten slagen! Laten we die test er nog eens bij pakken en bekijken hoe deze eruitziet:
func TestSecondHandAt30Seconds(t *testing.T) {
tm := time.Date(1337, time.January, 1, 0, 0, 30, 0, time.UTC)
want := clockface.Point{X: 150, Y: 150 + 90}
got := clockface.SecondHand(tm)
if got != want {
t.Errorf("Got %v, wanted %v", got, want)
}
}Probeer de test uit te voeren
clockface_acceptance_test.go:28: Got {150 60}, wanted {150 240}Schrijf genoeg code om de test te laten slagen
We moeten drie dingen doen om onze eenheidsvector om te zetten in een punt op de SVG:
Naar de lengte van de wijzer schalen
Spiegelen over de X-as om rekening te houden met het feit dat de SVG een oorsprong heeft in de linkerbovenhoek
Vertaal het naar de juiste positie (zodat het afkomstig is van een oorsprong van (150,150))
Laat de echte lol beginnen!
// SecondHand is the unit vector of the second hand of an analogue clock at time `t`
// represented as a Point.
func SecondHand(t time.Time) Point {
p := secondHandPoint(t)
p = Point{p.X * 90, p.Y * 90} // scale
p = Point{p.X, -p.Y} // flip
p = Point{p.X + 150, p.Y + 150} // translate
return p
}Schaal, spiegel en vertaal in precies die volgorde. Hoera, wiskunde!
PASS
ok clockface 0.007sRefactor
Er zijn hier een paar magische getallen die als constanten moeten worden gebruikt, dus laten we dat doen:
const secondHandLength = 90
const clockCentreX = 150
const clockCentreY = 150
// SecondHand is the unit vector of the second hand of an analogue clock at time `t`
// represented as a Point.
func SecondHand(t time.Time) Point {
p := secondHandPoint(t)
p = Point{p.X * secondHandLength, p.Y * secondHandLength}
p = Point{p.X, -p.Y}
p = Point{p.X + clockCentreX, p.Y + clockCentreY} //translate
return p
}Teken de klok
Nou ja... de secondewijzer in ieder geval...
Laten we dit doen, want er is niets erger dan geen waarde te leveren terwijl het er maar ligt te wachten om de wereld in te gaan om mensen te verbazen. Laten we eens een secondewijzer tekenen!
We gaan een nieuwe map toevoegen onder onze hoofdmap voor het clockface-pakket, genaamd (verwarrend genoeg) clockface. Daarin plaatsen we het main-pakket dat het binaire bestand voor een SVG-bestand aanmaakt:
|-- clockface
| |-- main.go
|-- clockface.go
|-- clockface_acceptance_test.go
|-- clockface_test.goIn main.go begin je met deze code, maar wijzig je de import voor het clockface-pakket zodat deze naar uw eigen versie verwijst:
package main
import (
"fmt"
"io"
"os"
"time"
"learn-go-with-tests/math/clockface" // REPLACE THIS!
)
func main() {
t := time.Now()
sh := clockface.SecondHand(t)
io.WriteString(os.Stdout, svgStart)
io.WriteString(os.Stdout, bezel)
io.WriteString(os.Stdout, secondHandTag(sh))
io.WriteString(os.Stdout, svgEnd)
}
func secondHandTag(p clockface.Point) string {
return fmt.Sprintf(`<line x1="150" y1="150" x2="%f" y2="%f" 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>`O jee, ik probeer met deze puinhoop geen prijzen te winnen voor mooie code, maar het doet zijn werk. Het schrijft een SVG naar os.Stdout, string voor string tegelijk.
Bouw deze code
go builden voer het uit, waarbij de uitvoer naar een bestand wordt verzonden
./clockface > clock.svgJe zou iets moeten zien als hieronder:
En dit is hoe de code eruit ziet.
Refactor
Dit stinkt. Nou ja, het stinkt niet echt, maar ik ben er niet blij mee.
Die hele
SecondHand-functie is super gekoppeld aan het feit dat het een SVG is... zonder SVG's te noemen of daadwerkelijk een SVG te produceren...... terwijl ik tegelijkertijd geen enkele SVG-code test.
Ja, ik denk dat ik een fout heb gemaakt. Dit voelt verkeerd. Laten we proberen het te herstellen met een meer SVG-gerichte test.
Wat zijn onze opties? We zouden kunnen testen of de tekens die uit de SVGWriter komen, dingen bevatten die lijken op het soort SVG-tag dat we voor een bepaalde tijd verwachten. Bijvoorbeeld:
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)
}
}Maar is dat echt een verbetering?
De test slaagt niet alleen als ik geen geldige SVG produceer (er wordt immers alleen getest of een tekenreeks in de uitvoer voorkomt), maar mislukt ook als ik een kleine, onbelangrijke wijziging in die tekenreeks aanbreng, bijvoorbeeld als ik een extra spatie tussen de kenmerken toevoeg.
De grootste valkuil is dat ik een datastructuur (XML) test door de representatie ervan te bekijken als een reeks tekens, als een string. Dit is nooit een goed idee, want het levert problemen op zoals die ik hierboven heb geschetst: een test die te kwetsbaar en niet gevoelig genoeg is. Een test die het verkeerde test!
De enige oplossing is dus om de uitvoer als XML te testen. En daarvoor moeten we het parsen.
Parsen van XML
encoding/xml is het Go-pakket dat alles kan afhandelen wat te maken heeft met eenvoudige XML-parsing.
De functie xml.Unmarshal neemt een []byte aan XML-gegevens en een aanwijzer naar een struct waarin deze moet worden gedeserialiseerd.
We hebben dus een structuur nodig om onze XML in te deserialiseren. We zouden wat tijd kunnen besteden aan het uitzoeken van de juiste namen voor alle knooppunten en attributen, en hoe we de juiste structuur moeten schrijven, maar gelukkig heeft iemand een programma voor zek geschreven dat al dat zware werk voor ons automatiseert. Sterker nog, er is een online versie op https://xml-to-go.github.io/. Plak de SVG van boven in het bestand in één vak en - bam - daar verschijnt:
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 kunnen hier indien nodig aanpassingen aan doen (zoals de naam van de struct wijzigen naar SVG), maar het is zeker goed genoeg om mee te beginnen. Plak de struct in het bestand clockface_acceptance_test en laten we er een test mee schrijven:
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 schrijven de uitvoer van clockface.SVGWriter naar een bytes.Buffer en Unmarshal deze vervolgens naar een SVG. Vervolgens bekijken we elke Line in de SVG om te zien of deze de verwachte X2- en Y2-waarden heeft. Als we een match vinden, keren we eerder terug (en slagen we voor de test); zo niet, dan falen we met een (hopelijk) informatief bericht.
./clockface_acceptance_test.go:41:2: undefined: clockface.SVGWriterHet lijkt erop dat we SVGWriter.go moeten creëren...
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="%f" y2="%f" 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>`De mooiste SVG-schrijf oplossing? Nee. Maar hopelijk doet hij zijn werk...
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>Oeps! De %f-opmaakrichtlijn drukt onze coördinaten af met het standaardnauwkeurigheidsniveau van zes decimalen. We moeten expliciet aangeven welk nauwkeurigheidsniveau we voor de coördinaten verwachten. Laten we zeggen drie decimalen.
fmt.Fprintf(w, `<line x1="150" y1="150" x2="%.3f" y2="%.3f" style="fill:none;stroke:#f00;stroke-width:3px;"/>`, p.X, p.Y)En nadat we onze verwachtingen in de test hebben bijgewerkt
x2 := "150.000"
y2 := "60.000"Krijgen we:
PASS
ok clockface 0.006sNu kunnen we onze main functie inkorten:
package main
import (
"os"
"time"
"learn-go-with-tests/math/clockface"
)
func main() {
t := time.Now()
clockface.SVGWriter(os.Stdout, t)
}Dit is hoe de code er nu uit zou moeten zien.
En we kunnen een test voor een ander tijdstip schrijven volgens hetzelfde patroon, maar niet voordat...
Refactor
Drie dingen vallen op:
We testen niet echt of alle informatie aanwezig is. Hoe zit het bijvoorbeeld met de
x1-waarden?En die attributen voor
x1etc. zijn toch geenstrings? Het zijn getallen!Maakt het mij echt uit hoe de
stijlvan de wijzer is? Of, wat dat betreft, de legeTextnode die doorzakis gegenereerd?
Dat kunnen we beter. Laten we een paar aanpassingen doen aan de SVG-structuur en de tests om alles scherper te maken.
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"`
}Hier heb ik
De belangrijke onderdelen van de structuur de
Lineen deCircleapart benoemdDe numerieke kenmerken omgezet naar
float64's in plaats vanstrings.De ongebruikte attributen
StyleenTextverwijderdSvghernoemd naarSVGomdat dit het juiste was om te doen
Hiermee kunnen we nauwkeuriger bepalen welke regel we zoeken:
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)
}Ten slotte kunnen we een voorbeeld nemen aan de tabellen van de unit-tests en een hulpfunctie schrijven die containsLine(line Line, lines []Line) bool toelaat om deze tests echt te laten schitteren:
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)
}
})
}
}
func containsLine(l Line, ls []Line) bool {
for _, line := range ls {
if line == l {
return true
}
}
return false
}Hier zie hoe de code eruit ziet
Dit noem ik nog eens een acceptatietest!
Schrijf eerst je test
Zo, dat is de secondewijzer. Laten we nu beginnen met de minutenwijzer.
func TestSVGWriterMinuteHand(t *testing.T) {
cases := []struct {
time time.Time
line Line
}{
{
simpleTime(0, 0, 0),
Line{150, 150, 150, 60},
},
}
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)
}
})
}
}Probeer de test uit te voeren
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}]We kunnen beter beginnen met het bouwen van andere wijzers. Net zoals we de tests voor de secondewijzer hebben gemaakt, kunnen we itereren om de volgende set tests te produceren. We zullen onze acceptatietest opnieuw uit-commentariëren terwijl we dit werkend krijgen:
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)
}
})
}
}Probeer de test uit te voeren
./clockface_test.go:59:11: undefined: minutesInRadiansSchrijf de minimale hoeveelheid code om de test te laten uitvoeren en de falende test output te controleren
func minutesInRadians(t time.Time) float64 {
return math.Pi
}Herhaal voor de nieuwe eisen
Oké, laten we nu eens wat echt werk doen. We zouden de minutenwijzer kunnen modelleren alsof hij elke hele minuut beweegt, zodat hij 'springt' van 30 naar 31 minuten, zonder tussendoor te bewegen. Maar dat zou er een beetje raar uitzien. Wat we willen is dat hij elke seconde een klein beetje beweegt. Laten we daar de test voor schrijven.
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)
}
})
}
}Hoeveel is dat kleine beetje? Nou...
Zestig seconden in een minuut
dertig minuten in een halve draai van de cirkel (
math.Piradialen)dus
30 * 60seconden in een halve draai.Dus als de tijd 7 seconden na het uur is ...
... verwachten we de minutenwijzer te zien op
7 * (math.Pi / (30 * 60))radialen voorbij de 12.
Probeer de test uit te voeren
clockface_test.go:62: Wanted 0.012217304763960306 radians, but got 3.141592653589793Schrijf genoeg code om de test te laten slagen
In de onsterfelijke woorden van Jennifer Aniston: Here comes the science bit
func minutesInRadians(t time.Time) float64 {
return (secondsInRadians(t) / 60) +
(math.Pi / (30 / float64(t.Minute())))
}In plaats van helemaal opnieuw uit te rekenen hoe ver de minutenwijzer per seconde rond de wijzerplaat moet worden gedraaid, kunnen we hier gewoon de functie secondsInRadians gebruiken. Voor elke seconde beweegt de minutenwijzer 1/60e van de hoek die de secondewijzer beweegt.
secondsInRadians(t) / 60Vervolgens voegen we de beweging voor de minuten toe, vergelijkbaar met het beweging van de secondewijzer.
math.Pi / (30 / float64(t.Minute()))En...
PASS
ok clockface 0.007sDat valt mee toch!? Dit is hoe de code er nu uit zou moeten zien
Herhaal voor nieuwe eisen
Moet ik meer gevallen toevoegen aan de minutesInRadians-test? Op dit moment zijn er maar twee. Hoeveel gevallen heb ik nodig voordat ik verder ga met het testen van de minuteHandPoint-functie?
Een van mijn favoriete TDD-citaten, vaak toegeschreven aan Kent Beck, is
Schrijf tests totdat angst omslaat in verveling.
En eerlijk gezegd ben ik het beu om die functie te testen. Ik weet wel hoe het werkt, dus op naar de volgende.
Schrijf eerst de test
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)
}
})
}
}Probeer de test uit te voeren
./clockface_test.go:79:11: undefined: minuteHandPointSchrijf de minimale hoeveelheid code om de test te laten uitvoeren en de falende test output te controleren
func minuteHandPoint(t time.Time) Point {
return Point{}
}clockface_test.go:80: Wanted {0 -1} Point, but got {0 0}Schrijf genoeg code om de test te laten slagen
func minuteHandPoint(t time.Time) Point {
return Point{0, -1}
}PASS
ok clockface 0.007sHerhaal voor nieuwe eisen
En nu wat echt werk
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)
}
})
}
}clockface_test.go:81: Wanted {-1 0} Point, but got {0 -1}Schrijf genoeg code om de test te laten slagen
Een snelle kopie en plak van de secondHandPoint-functie met een paar kleine aanpassingen zou voldoende moeten zijn...
func minuteHandPoint(t time.Time) Point {
angle := minutesInRadians(t)
x := math.Sin(angle)
y := math.Cos(angle)
return Point{x, y}
}PASS
ok clockface 0.009sRefactor
We hebben zeker wat herhaling in de minuteHandPoint en secondHandPoint, dat weten we omdat we de ene gewoon hebben gekopieerd en geplakt om de andere te maken. Laten we het opdrogen met een functie.
func angleToPoint(angle float64) Point {
x := math.Sin(angle)
y := math.Cos(angle)
return Point{x, y}
}en we kunnen minuteHandPoint en secondHandPoint herschrijven als one-liners:
func minuteHandPoint(t time.Time) Point {
return angleToPoint(minutesInRadians(t))
}func secondHandPoint(t time.Time) Point {
return angleToPoint(secondsInRadians(t))
}PASS
ok clockface 0.007sNu kunnen we de acceptatietest uit commentaar halen en aan de slag gaan met het tekenen van de minutenwijzer.
Schrijf genoeg code om de test te laten slagen
De minuteHand-functie is een kopieer-en-plakbewerking van secondHand met enkele kleine aanpassingen, zoals het declareren van een minuteHandLength:
const minuteHandLength = 80
//...
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)
}En een aanroep ervan in onze SVGWriter-functie:
func SVGWriter(w io.Writer, t time.Time) {
io.WriteString(w, svgStart)
io.WriteString(w, bezel)
secondHand(w, t)
minuteHand(w, t)
io.WriteString(w, svgEnd)
}Nu zouden we moeten zien dat TestSVGWriterMinuteHand het volgende resultaat geeft:
PASS
ok clockface 0.006sMaar het bewijs van de pudding zit in het eten. Als we nu ons clockface programma compileren en uitvoeren, zouden we iets moeten zien als
Refactor
Laten we de duplicatie uit de functies secondHand en minuteHand verwijderen en alle logica voor schaal, flip en vertaling op één plek onderbrengen.
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 clockface 0.007sDit is waar we nu zijn aangekomen.
Zo... nu hoef je alleen nog maar de kleine wijzer te doen!
Schrijf eerst je test
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 hour hand line %+v, in the SVG lines %+v", c.line, svg.Line)
}
})
}
}Probeer de test uit te voeren
clockface_acceptance_test.go:113: Expected to find the hour 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}]Laten we dit nog even buiten beschouwing laten totdat we wat dekking hebben met de tests op lager niveau:
Schrijf de eerste test
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)
}
})
}
}Probeer de test uit te voeren
./clockface_test.go:97:11: undefined: hoursInRadiansSchrijf de minimale hoeveelheid code om de test te laten uitvoeren en de falende test output te controleren
func hoursInRadians(t time.Time) float64 {
return math.Pi
}PASS
ok clockface 0.007sHerhaal voor nieuwe eisen
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)
}
})
}
}Probeer de test uit te voeren
clockface_test.go:100: Wanted 0 radians, but got 3.141592653589793Schrijf genoeg code om de test te laten slagen
func hoursInRadians(t time.Time) float64 {
return (math.Pi / (6 / float64(t.Hour())))
}Herhaal voor nieuwe eisen
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)
}
})
}
}Probeer de test uit te voeren
clockface_test.go:101: Wanted 4.71238898038469 radians, but got 10.995574287564276Schrijf genoeg code om de test te laten slagen
func hoursInRadians(t time.Time) float64 {
return (math.Pi / (6 / (float64(t.Hour() % 12))))
}Bedenk wel dat dit geen 24-uursklok is. Om de rest van het huidige uur te berekenen, gedeeld door 12, moeten we de restoperator gebruiken.
PASS
ok learn-go-with-tests/math/clockface 0.008sSchrijf eerst je test
Laten we nu proberen de kleine wijzer op de klok te draaien, gebaseerd op de minuten en seconden die verstreken zijn.
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)
}
})
}
}Probeer de test uit te voeren
clockface_test.go:102: Wanted 0.013089969389957472 radians, but got 0Schrijf genoeg code om de test te laten slagen
Opnieuw is er nu wat denkwerk nodig. We moeten de uurwijzer een klein beetje verplaatsen voor zowel de minuten als de seconden. Gelukkig hebben we al een hoek voor de minuten en de seconden bij de hand: die van minutenInRadians. Die kunnen we hergebruiken!
De enige vraag is dus met welke factor we die hoek moeten verkleinen. Een volledige draai is één uur voor de minutenwijzer, maar voor de urenwijzer is dat twaalf uur. Dus delen we de hoek die minutesInRadians oplevert gewoon door twaalf:
func hoursInRadians(t time.Time) float64 {
return (minutesInRadians(t) / 12) +
(math.Pi / (6 / float64(t.Hour()%12)))
}en zie:
clockface_test.go:104: Wanted 0.013089969389957472 radians, but got 0.01308996938995747De komma berekening slaat weer toe.
Laten we onze test bijwerken om grofweg EqualFloat64 te gebruiken voor de vergelijking van de hoeken.
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 clockface 0.007sRefactor
Als we rouglyEqualFloat64 gaan gebruiken in een van onze radialentests, moeten we het waarschijnlijk voor alle tests gebruiken. Dat is een mooie en simpele refactoring, waardoor het er zo uitziet.
Uurwijzer
Oké, het is tijd om te berekenen waar de kleine wijzer naartoe gaat door de eenheidsvector te berekenen.
Schrijf eerst je test
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)
}
})
}
}Wacht, ga ik twee testcases tegelijk schrijven? Is dit geen slechte TDD?
Over TDD-fanatisme
Test Driven Development is geen religie. Sommige mensen doen misschien alsof dat wel zo is (meestal mensen die geen TDD doen) maar graag op Twitter of Dev.to klagen dat het alleen door fanatici wordt gedaan en dat ze 'pragmatisch' zijn als ze geen tests schrijven. Maar het is geen religie. Het is een tool.
Ik weet welke twee tests het worden. Ik heb twee andere wijzers op exact dezelfde manier getest. En ik weet ook al wat mijn implementatie wordt. Ik heb een functie geschreven voor het algemene geval waarbij een hoek in de minutenwijzeriteratie in een punt wordt veranderd.
Ik ga de TDD-ceremonie niet zomaar doorploegen. TDD is een techniek die me helpt de code die ik schrijf, en de code die ik ga schrijven, beter te begrijpen. TDD geeft me feedback, kennis en inzicht. Maar als ik die kennis al heb, ga ik de ceremonie niet zomaar doorploegen. Noch tests, noch TDD zijn een doel op zich.
Mijn zelfvertrouwen is toegenomen, dus ik heb het gevoel dat ik grotere stappen vooruit kan zetten. Ik ga een paar stappen 'overslaan', want ik weet waar ik ben, ik weet waar ik naartoe ga en ik heb deze weg al eerder bewandeld.
Maar let op: ik sla het schrijven van de tests niet helemaal over, ik schrijf ze nog steeds eerst. Ze verschijnen alleen in minder gedetailleerde stukjes.
Probeer de test uit te voeren
./clockface_test.go:119:11: undefined: hourHandPointSchrijf genoeg code om de test te laten slagen
func hourHandPoint(t time.Time) Point {
return angleToPoint(hoursInRadians(t))
}Zoals ik al zei, ik weet waar ik ben en ik weet waar ik naartoe ga. Waarom zou ik anders doen alsof? De tests zullen me binnenkort vertellen of ik het mis heb.
PASS
ok learn-go-with-tests/math/clockface 0.009sTeken de uur wijzer
En tot slot tekenen we de uren wijzer. We kunnen die acceptatietest invoeren door hem in te schakelen:
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 hour hand line %+v, in the SVG lines %+v", c.line, svg.Line)
}
})
}
}Probeer de test uit te voeren
clockface_acceptance_test.go:113: Expected to find the hour 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}]Schrijf genoeg code om de test te laten slagen
En nu kunnen we onze laatste aanpassingen maken aan de SVG-schrijfconstanten en -functies:
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)
}
En dus...
ok clockface 0.007sLaten we dit controleren door ons clockface programma te compileren en uit te voeren.
Refactor
Kijkend naar clockface.go, zie je een paar 'magische getallen' rondzweven. Ze zijn allemaal gebaseerd op het aantal uren/minuten/seconden dat er in een halve draai rond een wijzerplaat zit. Laten we de betekenis ervan eens herstructureren.
const (
secondsInHalfClock = 30
secondsInClock = 2 * secondsInHalfClock
minutesInHalfClock = 30
minutesInClock = 2 * minutesInHalfClock
hoursInHalfClock = 6
hoursInClock = 2 * hoursInHalfClock
)Waarom doen we dit? Nou, het maakt duidelijk wat elk getal in de vergelijking betekent. Als we later terugkomen bij deze code, zullen deze namen ons helpen te begrijpen waar de waarden voor staan.
Bovendien, mochten we ooit echt heel VREEMDE klokken willen maken (bijvoorbeeld met 4 uur voor de uurwijzer en 20 seconden voor de secondewijzer) dan zouden deze constanten gemakkelijk parameters kunnen worden. We helpen die deur open te houden (zelfs als we er nooit doorheen gaan).
Samenvattend
Moeten we nog iets anders doen?
Laten we onszelf eerst een schouderklopje geven: we hebben een programma geschreven dat een SVG-wijzerplaat maakt. Het werkt en het is geweldig. Het maakt maar één soort wijzerplaat, maar dat is prima! Misschien wil je maar één soort wijzerplaat. Er is niets mis met een programma dat een specifiek probleem oplost en niets anders.
Een programma... en een bibliotheek
Maar de code die we hebben geschreven, lost wel een meer algemene reeks problemen op die te maken hebben met het tekenen van een wijzerplaat. Omdat we tests hebben gebruikt om elk klein onderdeel van het probleem afzonderlijk te bekijken, en omdat we die isolatie met functies hebben vastgelegd, hebben we een zeer redelijke kleine API gebouwd voor wijzerplaatberekeningen.
We kunnen aan dit project werken en het omzetten in iets algemeners: een bibliotheek voor het berekenen van hoeken en/of vectoren van wijzerplaten.
Het is eigenlijk een heel goed idee om de bibliotheek samen met het programma aan te bieden. Het kost ons niets, maar vergroot wel de bruikbaarheid van ons programma en helpt bij het documenteren van de werking ervan.
API's zouden met programma's meegeleverd moeten worden, en vice versa. Een API waarvoor je C-code moet schrijven en die niet eenvoudig vanaf de opdrachtregel kan worden aangeroepen, is moeilijker te leren en te gebruiken. En omgekeerd is het een enorme opgave om interfaces te hebben waarvan de enige open, gedocumenteerde vorm een programma is, waardoor je ze niet eenvoudig vanuit een C-programma kunt aanroepen.
-- Henry Spencer, uit The Art of Unix Programming
In mijn uiteindelijke versie van dit programma heb ik de niet-geëxporteerde functies in clockface omgezet naar een openbare API voor de bibliotheek, met functies om de hoek en eenheidsvector voor elke wijzer te berekenen. Ik heb het SVG-generatiegedeelte ook opgesplitst in een eigen pakket, svg, dat vervolgens rechtstreeks door het clockface-programma wordt gebruikt. Uiteraard heb ik elk van de functies en pakketten gedocumenteerd.
Over SVG's gesproken...
De meest waardevolle test
Ik weet zeker dat je hebt gemerkt dat het meest geavanceerde stukje code voor het verwerken van SVG's helemaal niet in onze applicatiecode zit; het zit in de testcode. Moeten we ons hier ongemakkelijk bij voelen? Zouden we niet zoiets moeten doen als:
een template van
text/templategebruiken?een XML-bibliotheek gebruiken (zoals we in onze test doen)?
een SVG-bibliotheek gebruiken?
We zouden onze code kunnen refactoren om al deze dingen te doen, en dat kunnen we doen omdat het niet uitmaakt hoe we onze SVG produceren; wat belangrijk is, is wat we produceren: een SVG. Het deel van ons systeem dat het meest over SVG's moet weten (en dus het meest strikt moet zijn over wat een SVG is) is de test voor de SVG-uitvoer: het moet voldoende context en kennis hebben over wat een SVG is, zodat we er zeker van kunnen zijn dat we een SVG uitgeven. Het wat van een SVG zit in onze tests; het hoe in de code.
We vonden het misschien vreemd dat we zoveel tijd en moeite in die SVG-tests staken, het importeren van een XML-bibliotheek, het parsen van XML, het refactoren van de structs, maar die testcode is een waardevol onderdeel van onze codebase. Mogelijk zelfs waardevoller dan de huidige productiecode. Het helpt garanderen dat de output altijd een geldige SVG is, ongeacht wat we ervoor gebruiken.
Tests zijn geen tweederangsburgers. Het is geen 'wegwerpcode'. Goede tests gaan veel langer mee dan de versie van de code die ze testen. Je moet nooit het gevoel hebben dat je 'te veel tijd' besteedt aan het schrijven van je tests. Het is een investering.
Laatst bijgewerkt