数学

未匹配的标注

数学

你可以在这里找到本章的所有代码

尽管现在的计算机有能力以闪电般的速度完成巨额计算,但普通的开发人员也很少使用数学来完成他们的工作。但是今天不会了!今天我们将用数学来解决一个 实际 问题,而不是枯燥的数学——我们将会用到三角学和向量以及各种各样的你总是说你高中毕业后不会用到的东西。

问题

我们将会制作一个 SVG 时钟。不是数字时钟——不,那很容易——一个有指针的 模拟 时钟。您并不需要花哨的东西,只是需要一个从 time 包中取出 Time 并生成一个 SVG 时钟的函数,所有指针——小时、分钟和秒——都指向正确的方向。这能有多难呢?

首先,我们需要一个 SVG 的时钟。SVG 是一种极好的图像格式,可以通过编程方式进行操作,因为它们是用 XML 描述的一系列形状编写的。所以,这个时钟:

数学

svg 时钟

描述如下:

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

这是一个有三条线的圆,每条线从圆的中间(x = 150, y = 150)开始,并在一定距离处结束。

所以我们要做的就是以某种方式重建上面的结构,但是要改变这些线使它们在给定的时间内指向适当的方向。

验收测试

在我们陷入困境之前,让我们考虑一下验收测试。我们有一个时钟的例子,让我们考虑一下那些参数是重要的。

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

时钟的中心(这一行的属性 x1y1)对于时钟的每只指针都是相同的。需要为每只时钟指针更改的数字(用于构建 SVG 的参数)是 x2y2 属性。我们需要一个 X 和一个 Y 来代表时钟的每一个指针。

可以 考虑更多的参数——表盘的半径、SVG 的大小、指针的颜色、形状等等……但最好先用一个简单具体的解决方案来解决一个简单具体的问题,然后再迭代新功能。

我们会说

  • 时钟的圆心为 (150, 150)

  • 时针长度 50

  • 分针长度 80

  • 秒针长度 90

SVG 需要注意的点:原点(0,0)—— 位于 左上角,而不是我们期望的 左下角。当我们计算线段长度时,一定要记住这点。

最后,我并不决定 如何 构造 SVG —— 我们可以使用 text/template 包中的模版,或者我们可以将字节发送到 bytes.Buffer 或写入器。但是我们知道我们需要这些数据,所以让我们集中精力测试产生这些数据的东西。

首先编写测试

我们的第一个测试如下:

package clockface_test

import (
    "testing"
    "time"

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

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

还记得 SVG 是如何从左上角绘制坐标的吗? 为了在午夜放置秒针,我们预计它不会从X轴的钟面中心移动 —— 仍然是 150 - 并且Y 轴是从中心开始向上的手的长度; 150 - 90。

尝试运行测试

这将排除围绕丢失的函数和类型的预期故障:

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

因此,秒针的尖端应该指向一个 Point,并有一个函数来得到它。

编写最少的代码让测试跑起来并查看失败输出

让我们来编译实现这些类型的代码

package clockface

import "time"

// Point 表示二维笛卡尔坐标
type Point struct {
    X float64
    Y float64
}

// SecondHand 是指针式时钟在 `t` 时刻的秒针的单位矢量
// 表示一个 Point
func SecondHand(t time.Time) Point {
    return Point{}
}

现在我们会得到

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

编写足够的代码让测试通过

当我们得到预期的失败时,我们可以填写 HandsAt 的返回值:

// SecondHand 是指针式时钟在 `t` 时刻的秒针的单位矢量
// 表示一个 Point
func SecondHand(t time.Time) Point {
    return Point{150, 60}
}

看,测试通过了。

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

重构

还不需要重构——代码还不够!

新需求

我们可能还需要做一些工作,不仅仅是返回一个显示午夜的时钟。

首先编写测试

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

同样的思路,但是现在秒针是 向下 的所以我们给 Y 轴 上长度。

这样编译,我们如何让它通过呢?

思考时间

我们怎么解决这个问题?

每分钟秒针都要经过 60 个相同的状态,指向 60 个不同的方向。当它是 0 秒时,它指向表盘的顶部,当它是 30 秒时,它指向表盘的底部。很简单。

所以,如果我想知道秒针指向哪个方向,比如说,37 秒,我想要的是 12 点和 37/60 之间的夹角。以角度为单位是 (360 / 60 ) * 37 = 222 但是,它是一圈的 37/60 更容易记住。

但角度只是事情的一半; 我们需要知道秒针尖端指向的 X 和 Y 坐标。我们怎么解决这个问题?

数学

想象一个半径为 1 的圆,以原点(坐标 0, 0)为圆心。

数学

单位圆的图片

这被称为 "单位圆",因为……半径是 1 个单位!

圆的周长是由网格上的点构成的——更多的坐标。每个坐标的 x 和 y 分量构成一个三角形,三角形的斜边总是 1——圆的半径。

在圆周上定义一个点的单位圆的图片

现在,三角函数让我们求出每个三角形的 X 和 Y 的长度如果我们知道它们与原点的夹角。X 坐标是 cos(a),Y 坐标是 sin(a) 其中 a 是直线和(正) X 轴之间的夹角。

数学

一条射线的 x 和 y 元素分别定义为 cos(a) 和 sin(a) 的单位圆图像,其中 a 是射线与 x 轴的夹角

(如果你不相信这个, 去看看维基百科)

最后一个转折——因为我们想要测量从 12 点开始的角度,而不是从 X 轴开始的角度( 3 点),我们需要把轴调换一下;现在 x = sin(a) y = cos(a)。

单位圆射线由 y 轴的角度定义

现在我们知道如何求出秒针的角度(每秒钟是圆的 1/60 )以及 X 和 Y 坐标。我们需要 sincos 的函数。

math

幸运的事 Go 的 math 包中两个都有,但是有一个小问题我们需要重新考虑一下;我们看一下 math.Cos 的描述:

Cos 返回弧度 x 的余弦值。

它的单位是弧度。弧度是什么? 我们定义了一个整圈是 2π 弧度,而不是定义的全部转一圈是由 360 度。我们有很好的理由这样做,但我们不会深入讨论。

现在我们已经阅读了一些,学习了一些,思考了一些,我们可以写我们的下一个测试。

首先编写测试

数学都很难,而且令人困惑。我不确定我理解的是否正确——所以开始写测试!我们不需要一次解决所有的问题,让我们开始算出正确的角度,以弧度为单位,在特定的时间为秒针算出正确的角度,以弧度为单位,在特定的时间为秒针算出正确的角度。

我将在 clockface 包中编写这些测试;它们可能永远不会被导出,而且一旦我对发生的事情有了更好的把握,它们可能会被删除(或移动)。

我还将注释掉我在处理这些测试时正在处理的验收测试 —— 我不想在这个测试通过之前分心

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

这里我们测试的是一分钟后的 30 秒应该把秒针放在时钟的中间。这是我们第一次使用 math 包! 如果一个完整的圆的是 2π 弧度,我们可以知道半圆的弧度是 π。math.Pi 为我们提供了 π 值。

尝试运行测试

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

编写最少量的代码让测试跑起来并检查失败输出

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

编写足够的代码让测试通过

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

重构

不需要重构

新需求

现在我们可以扩展测试以覆盖更多的场景。我将跳过一点,展示一些已经重构的测试代码—应该足够清楚我是如何到达我想要的地方的。

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

我添加了一些帮助函数,使编写这个基于表的测试变得不那么繁琐。testName 将时间转换为数字手表的格 (HH:MM:SS), simpleTime 只使用我们真正关心的部分构造 time.Time(同样,小时,分钟和秒)。

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

这两个函数应该有助于使这些测试(以及未来的测试)更容易编写和维护。

这给了我们一些不错的测试输出:

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

是时候实现我们上面讨论的所有数学内容了:

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

一秒是( 2π/ 60 )弧度……消掉了我们得到的 2π/ 30 弧度。将其乘以秒数(以 float64 的形式),现在所有测试都应该通过了……

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

等等,什么?

可怕的浮点数

浮点数是非常不准确的。计算机在某种程度上只能真正处理整数和有理数。在处理小数时开始变得不准确,尤其是当我们像在 secondsInRadians 函数中那样对它们进行向上和向下分解时。通过将 math.Pi 除以 30,然后将其乘以 30,我们得出 的数字不再与 math.Pi 相同。

有两种解决方法:

  1. 与之共存

  2. 通过重构方程式来重构函数

现在方法 (1) 似乎并不吸引人了, 但它通常是使浮点数相等起作用的唯一方法。坦率地说,以无穷小分数表示误差并不重要,因此我们可以编写一个函数,为我们的角度定义一个「足够接近」的等式。但是有一种简单的方法可以使精度恢复:重新排列方程式,这样我们就不再进行除法和乘积运算。我们可以通过除以完成所有操作。

所以代替

numberOfSeconds  *  π / 30

我们可以写

π / (30 / numberOfSeconds)

这是等效的

在 Go 中:

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

我们通过了

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

新需求

我们已经讲过了第一部分,我们知道了以弧度为单位的秒针指向的角度。现在我们需要计算坐标。

同样,让我们尽可能地简单,只使用 unit circle;半径为 1 的圆。这意味着我们指针的长度均为 1,但是从好的方面来说,这意味着我们可以轻松地进行数学运算。

首先编写测试

func TestSecondHandVector(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)
            }
        })
    }
}

尝试运行测试

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

编写最少量的代码让测试跑起来并检查失败输出

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

编写足够的代码让测试通过

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

新需求

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

尝试运行测试

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

编写足够的代码让测试通过

还记得我们单位圆的图片吗?

射线的 x 和 y 元素分别定义为 cos(a) 和 sin(a) 的单位圆的图片,其中 a 是射线与 x 轴所成的角度

现在,我们需要编写 X 和 Y 的方程式。让我们将其写成几秒钟:

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

    return Point{x, y}
}

现在,我们得到

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

等等,什么? 看起来我们又一次被浮点数诅咒了——这两个意想不到的数字都是 无穷小——降到小数点后16位。因此,我们可以再次选择提高精度,或者只是说它们大致相等并继续我们的工作。

提高这些角度的精度的一种选择是使用 math / big 包中的有理类型 Rat。但是鉴于目标是绘制 SVG,而不是绘制月球着陆,我认为我们可以忍受一些误差。

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

我们定义了两个函数来定义两个 Points 之间的近似相等。

  • 如果 X 和 Y 元素之间的距离不超过 0.0000001,它们将起作用。

    这还是很准确的。

并且我们现在得到

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

重构

我对此仍然很满意

新需求

好吧,说 并不完全准确——实际上,我们现在要做的就是使验收测试通过!让我们回想一下它的样子:

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

尝试运行测试

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

编写足够的代码让测试通过

我们需要做三件事以将单位矢量转换为 SVG 上的一个点:

1.缩放到指针的长度

2.将其翻转到 X 轴上,因为要考虑到 SVG 的原点是左上角

3.将其平移到正确的位置(它来自于一个原点( 150,150 ))

快乐时光!

// SecondHand 是指针式时钟在 `t` 时刻的秒针的单位矢量。
// 表示为 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
}

按这个顺序缩放、翻转和平移。数学万岁!

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

重构

这里有一些神奇的数字应该作为常数提出来,我们来做一下

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
}

画钟

嗯...还是秒针...

让我们做这件事把 —— 因为没有什么比坐以待毙更糟糕的了。让我们画秒针!

我们将在我们的 clockface 包目录下创建一个新目录,名为 clockface。在这里,我们将放置 main 包,该包将构建 SVG 的二进制文件:

├── clockface

│   └── main.go

├── clockface.go

├── clockface_acceptance_test.go

└── clockface_test.go

main.go

package main

import (
    "fmt"
    "io"
    "os"
    "time"

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

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

天哪,我不是在试图用这堆乱七八糟的代码赢得任何奖项,但它确实做到了。它正在将 SVG 写到 os.Stdout —— 一次一个字符串。I

如果我们构建

go build

并运行它,将输出发送到文件中

./clockface > clock.svg

我们应该看到下面这样

只有秒针的时钟

重构

这太烂了。好吧,它不是很 ,但我对此并不满意。

  1. 整个 SecondHand 函数是与 SVG 紧密 联系在一起的... 没有提到 SVG 或实际生成 SVG

  2. ...同时,我没有测试任何 SVG 代码。

是的,我想我搞砸了。这感觉不对。让我们尝试以更以 SVG 为中心的测试并进行恢复。

我们有什么选择?好吧,我们可以尝试测试从 SVGWriter 输出的字符包含的内容看起来像我们期望在特定时间内使用的 SVG 标签。例如:

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

但这真的是进步吗?

如果没有生成有效的 SVG,它仍然会通过(因为它只是测试字符串是否出现在输出中),但它也将失败,如果我对字符串做了无关紧要的改变 —— 例如,添加一个额外的空间属性。

最大的问题是我正在测试一个数据结构——XML——通过查看它作为一系列字符的表示——作为一个字符串。这 从来都不是 一个好主意,因为它产生的问题就像我上面列出的那些:这个测试太脆弱又不够敏感。测试就是测试错误的!

因此,惟一的解决方案是将输出测试 为 XML。为了做到这一点,我们需要解析它。

解析 XML

encoding/xml 是一个可以处理所有与 XML 解析的 Go 包。

函数 xml.Unmarshall 接受 XML 数据的 []byte 和指向该结构体的指针,以便对其进行解组。

因此,我们需要一个结构来解压缩 XML。我们可以花一些时间来确定所有节点和属性的正确名称,以及如何编写正确的结构,但是,令人高兴的是,有人已经编写了一个 zek 程序,它将为我们自动完成这些艰苦的工作。更好的是,还有一个在线版本 www.onlinetool.io/xmltogo/

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

如果需要,我们可以对此进行调整(例如将结构的名称更改为 SVG),但这足够了。

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

我们将 clockface.SVGWriter 的输出写入 bytes.Buffer,然后将 Unmarshall 的输出写入 Svg。然后,我们查看 Svg 中的每个 Line,以查看它们是否具有预期的 X2Y2 值。如果我们找到匹配的,我们会尽早返回(通过测试);如果不是,我们将失败,并发送一条(希望的)信息性消息。

# 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]

看起来我们最好写一下 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>`

最美 SVG 的作者? 不,但是希望它将完成这项工作...

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

糟糕!%f 格式指令正在将坐标打印到默认精度级别——六位小数。我们应该明确我们对坐标的精度要求。假设小数点后三位。

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

在我们更新了对测试的期望之后

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

我们得到:

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

我们现在可以简化 main 函数:

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

而且我们可以按照相同的模式来编写另一个测试。

重构

坚持三件事:

  1. 我们并没有真正测试我们需要确保存在的所有信息—例如,x1 值?

  2. 另外,x1 它们的那些属性不是真的 strings 吗?他们是数字!

  3. 我真的在乎指针的 style 吗? 或者,对于这个问题,是由 zak 生成的空 Text 节点?

我们可以做得更好。让我们对 Svg 结构和测试进行一些调整,以增强所有功能。

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"`
}
  • 将结构的重要部分命名为类型 -- LineCircle

  • 将数字属性转换为 float64 而不是 string

  • 删除了未使用的属性,例如 StyleText

  • Svg 重命名为 SVG ,因为 这是正确的做法

这将使我们更准确地断言我们正在寻找的路线:

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

最后,我们可以借鉴单元测试的表格,我们可以编写一个帮助函数 containsLine(line, lines, lines [] line) bool 来真正让这些测试发挥作用:

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

尝试运行测试

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

我们最好开始构建其他一些时钟指针,就像我们为秒针生成测试一样,我们可以迭代生成以下测试集。我们将再次注释掉我们的验收测试:

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

尝试运行测试

# 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]

编写最少量的代码让测试跑起来,并

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

新需求

好吧,现在让我们做一些 真正的 工作。我们可以把分针模拟成每一分钟只移动一次,这样它就可以从过去30分钟「跳」到31分钟,而不需要移动。但这看起来有点糟糕。我们想要它做的是每秒钟 移动一点点

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

一点点是多少? 好吧...

  • 60 秒是 1 分钟

  • 半圈就是半小时 (math.Pi 弧度)

  • 所以 30 * 60 秒是半圈

  • 如果时间是 1 小时过 7 秒 ...

  • ... 我们期望 7 * (math.Pi / (30 * 60)) 分针超过 12 点的弧度

尝试运行测试

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

编写足够的代码让测试通过

用 Jennifer Aniston 的话说: 这是科学的一面

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

无需计算从头开始每秒钟将分针绕表盘走多远,而是可以利用 secondsInRadians 函数。分针每秒将移动秒针移动角度的 1/60。

secondsInRadians(t)  /  60

然后我们只需要按分钟增加动作——就像秒针的动作一样。

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

并且...

PASS

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

愉快与轻松。

新需求

我应该在 minutesInRadians 测试中添加更多案例吗?目前只有两个。在继续测试 minuteHandPoint 函数之前,我需要多少种情况?

我最喜欢的 TDD 格言之一,通常是 Kent Beck 说的

编写测试,直到恐惧转化为厌倦。

坦白地说,我已经厌倦了测试这个函数。我相信我知道它是如何工作的。所以,让我们继续进行下一个。

首先编写测试

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

尝试运行测试

# 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]

编写最少量的代码让测试跑起来并查看失败输出

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

编写足够的代码让测试通过

func minuteHandPoint(t time.Time) Point {
    return Point{0, -1}
}
PASS

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

新需求

现在进行一些实际工作

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

编写足够的代码让测试通过

快速复制并粘贴 secondHandPoint 函数并进行一些小的更改就可以做到这一点...

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

重构

我们肯定知道 minuteHandPointsecondHandPoint 中有一些重复 —— 我知道,因为我们只是复制粘贴一个来制作另一个。让我们用函数来解决它。

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

    return Point{x, y}
}

我们可以将 minuteHandPointsecondHandPoint 重写为一行:

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

现在我们可以取消对验收测试的注释,开始绘制分针

编写足够的代码让测试通过

快速复制和粘贴并做一些小的调整

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

但是事实胜于雄辩的证明 —— 如果我们现在编译并运行我们的 clockface 程序,我们应该会看到类似的结果

数学

一个有分针和秒针的时钟

重构

让我们从 secondHandminuteHand 中去除重复项,把所有的比例,翻转和转换逻辑都放在一个地方。

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

瞧……现在是时候了!

首先编写测试

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

尝试运行测试

--- FAIL: TestSVGWriterHourHand (0.00s)
    --- FAIL: TestSVGWriterHourHand/06:00:00 (0.00s)
        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}]
FAIL
exit status 1
FAIL    github.com/gypsydave5/learn-go-with-tests/math/v10/clockface    0.013s

再一次,让我们把这个注释出来,直到我们得到了一些低水平测试的覆盖率:

首先编写测试

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

尝试运行测试

# 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]

编写最少量的代码让测试跑起来并检查失败输出

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

新需求

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

尝试运行测试

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

编写足够的代码让测试通过

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

新需求

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

尝试运行测试

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

编写足够的代码让测试通过

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

记住,这不是一个 24 小时的时钟;我们必须使用余数运算符来得到当前小时的余数,然后除以 12。

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

首先编写测试

现在让我们试着根据过去的分针和秒针来移动时针

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

尝试运行测试

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

编写足够的代码让测试通过

同样,现在需要一些思考。我们需要把时针向前移动一点来表示分钟和秒。幸运的是,我们已经有一个角度来计算分钟和秒了 —— 一个是 minutesInRadians 返回的。我们可以再利用它!

所以唯一的问题是通过什么因素来减小这个角的大小。分针一整圈为一小时,但时针为十二小时。因此,我们只需将 minutesInRadians 返回的角度除以十二:

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

可以看到:

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

该死的浮点运算!

让我们更新测试以使用 roughlyEqualFloat64 进行角度比较。

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

重构

如果我们要在 一个 弧度测试中使用 roughlyEqualFloat64,我们可能应该对 所有 的这个测试都使用它。这是一个很简单的重构。

时针

好了,是时候通过计算单位向量来计算时针指向哪里了。

首先编写测试

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

等等,我是不是要 同时 抛出 两个 测试用例? 这不是 很糟糕的 TDD 吗?

TDD 狂热者

测试驱动的开发不是一种信仰。有些人可能会表现得像这样 —— 通常是那些不做 TDD 但乐于在 Twitter 或 Dev 上抱怨的人。只有狂热者才会这样做,他们在不写测试的时候是「务实的」。 但它不是信仰。它是工具。

知道 这两个测试将会干什么 —— 我用完全相同的方法测试了另外两个时钟指针 —— 我已经知道我的实现是什么 —— 我编写了一个函数,用于在分针迭代中改变角度。

我不打算为了 TDD 而勉强通过它。测试是帮助我编写优秀代码的工具。TDD 是一种可以帮助我编写更好代码的技术。测试和 TDD 本身不是目的。

我的信心增加了,所以我觉得我可以取得更大的进步。我要「跳过」几个步骤,因为我知道我在哪里,我知道我要去哪里,我以前就走过这条路.

但也请注意: 我并没有完全跳过编写测试。

尝试运行测试

# 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]

编写足够的代码让测试通过

func  hourHandPoint(t time.Time) Point {

  return  angleToPoint(hoursInRadians(t))

}

就像我说的,我知道我在哪里,我知道我要去哪里。为什么假装? 如果我错了,测试很快就会告诉我。

PASS

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

绘制时针

最后我们来画时针。我们可以通过取消注释来引入验收测试:

func  TestSVGWriterHourHand(t *testing.T)  {

 cases :=  []struct  {

 time time.Time

 line Line
  for  _, c :=  range cases {

 t.Run(testName(c.time),  func(t *testing.T)  {

 b := bytes.Buffer{}
}

尝试运行测试

--- FAIL: TestSVGWriterHourHand (0.00s)
    --- FAIL: TestSVGWriterHourHand/06:00:00 (0.00s)
        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}]
FAIL
exit status 1
FAIL    github.com/gypsydave5/learn-go-with-tests/math/v10/clockface    0.013s

编写足够的代码让测试通过

现在我们可以对 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)
}

所以

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

让我们通过编译并运行我们的 clockface 程序进行检查。

一个时钟

重构

看着 clockface.go, 有几个「神奇的数字」漂浮着。它们全都基于围绕一个表盘半圈有多少小时/分钟/秒。让我们进行重构,以便我们明确其含义。

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

为什么这样做? 它明确了方程中每个数的 意义。如果 —— ——我们回到这个代码,这些名称将帮助我们理解发生了什么。

此外,我们是否应该做一些非常非常奇怪的时钟 —— 时针是 4 小时,秒针是 20 秒 —— 这些常量很容易变成参数。我们正在帮忙打开那扇门 (即使我们从未经历过)。

结束

我们还需要做其他事情吗?

首先,让我们拍拍自己的背 —— 我们编写了一个制作 SVG 表盘的程序。它工作得很好。它只能做出一种表盘 —— 不过没关系!也许你只 想要 一种表盘。解决特定问题的程序没有任何问题。

一个程序... 和一个库

但是我们写的代码确实解决了一组通常的问题,比如绘制表盘。因为我们使用测试来独立地考虑问题的每一个小部分,因为我们用函数来编写隔离,所以我们为钟面计算构建了一个非常合理的小 API。

我们可以做这个项目,把它变成更通常的东西 —— 用于计算钟面角度和/或矢量的库。

实际上,随程序一起提供库是一个 非常好的主意。 它不需要我们付出任何代价,同时增加了我们程序的效用,并帮助记录它是如何工作的。

API 应该与程序一起提供,反之亦然。必须编写 C 代码才能使用的 API 很难从命令行中轻松调用,因此更难学习和使用。相反,如果接口的惟一开放的、文档化的形式是程序,那么就很难从 C 程序中调用它们。-- Henry Spencer, 在 Unix 编程的艺术

我最后的项目,我将 clockface 中未导出的函数变成了这个库的公共 API,其中的函数用于计算每个时钟指针的角度和单位向量。我还将 SVG 生成部分分解为它自己的包 svg,然后由 clockface 程序直接使用。当然,我已经记录了每个函数和包。

谈论 SVG...

最有价值的测试

我敢肯定,您已经注意到,用于处理 SVG 的最复杂的代码根本不在我们的应用程序代码中;它在我们的测试代码中。这应该让我们感到不舒服吗? 我们不应该做类似的事情吗

  • 使用 text/template 中的模版?

  • 使用 XML 库 (就像我们测试中所做的一样)?

  • 使用 SVG 库?

我们可以重构代码来做这些事情中的任何一件,因为我们可以这样做,因为我们如何生成 SVG 并不重要,重要的是我们生成的是一个 SVG。因此,我们的系统中最需要了解 SVG 的部分(也就是构成 SVG 的最严格的部分)是对 SVG 输出的测试;它需要有关于 SVG 的足够的上下文和知识,以便我们确信输出的是SVG。

我们可能会觉得奇怪,我们在那些 SVG 测试上投入了大量的时间和精力——导入 XML 库、解析 XML、重构结构——但是那些测试代码是我们代码库中有价值的一部分——可能比当前的产品代码更有价值。它将有助于确保输出始终是有效的 SVG,无论我们选择使用什么来生成它。

测试不是二等公民——它们不是一次性代码。好的测试将比他们测试的特定版本的代码持续更长的时间。永远不要觉得自己花了太多时间写测试。这通常是明智的投资。

原文地址 Learn Go with Tests

本文章首发在 LearnKu.com 网站上。

本译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。
上一篇 下一篇
贡献者:3
讨论数量: 0
发起讨论 只看当前版本


暂无话题~