ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

使用 Go 与 TDD 构建 SVG 模拟时钟:从单位圆三角函数到可验证的 SVG 输出

使用 Go 与 TDD 构建 SVG 模拟时钟:从单位圆三角函数到可验证的 SVG 输出 教程示例工程【免费下载链接】learn-go-with-testsLearn Go with test-driven development项目地址https://gitcode.com/gh_mirrors/le/learn-go-with-tests点击查看免费下载导读本篇技术指南以 learn-go-with-tests 仓库中的 math.md 章节为骨架完整还原一个用测试驱动开发TDD逐步实现输入一个time.Time输出一张指针指向正确的模拟时钟 SVG的真实项目。你将学会用单位圆与三角函数math.Sin/math.Cos把时间转换为弧度角、再转换为指针端点坐标理解浮点数精度问题与粗略相等断言策略并用encoding/xml编写真正解析 SVG 结构的验收测试。仓库中 math 目录下从 v1 到 vFinal 共十余个渐进版本每一步都能对照文档与源码验证。问题定义如何用程序画一张模拟时钟现代计算机擅长高速运算但日常开发中很少用到真正的数学。本章要解决一个真实问题生成一张模拟时钟的 SVG——不是简单显示数字的电子钟而是带有时针、分针、秒针且各指针指向正确方向的时钟。SVG 之所以适合程序化操作是因为它本质上是用 XML 描述的图形集合。仓库中 math/example_clock.svg 就是一个示例时钟其结构如下?xml version1.0 encodingUTF-8 standaloneno? !DOCTYPE svg PUBLIC -//W3C//DTD SVG 1.1//EN http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd svg xmlnshttp://www.w3.org/2000/svg width300 height300 viewBox0 0 300 300 version2.0 !-- bezel -- circle cx150 cy150 r100 stylefill:#fff;stroke:#000;stroke-width:5px;/ !-- hour hand -- line x1150 y1150 x2114.150000 y2132.260000 stylefill:none;stroke:#000;stroke-width:7px;/ !-- minute hand -- line x1150 y1150 x2101.290000 y299.730000 stylefill:none;stroke:#000;stroke-width:7px;/ !-- second hand -- line x1150 y1150 x277.190000 y2202.900000 stylefill:none;stroke:#f00;stroke-width:3px;/ /svg时钟就是一个圆加三条线所有line都从圆心x150, y150出发终点x2/y2各不相同。因此我们的任务可以归结为给定一个时间算出每根指针终点x2, y2应该是什么。在动手之前先固定一组简单的、具体的参数文档强调先解决简单具体的问题再逐步泛化时钟圆心固定为 (150, 150)时针长度 50分针长度 80秒针长度 90注意 SVG 的坐标原点 (0,0) 在左上角而非左下角Y 轴方向向下这一点在换算坐标时至关重要。验收测试Acceptance Test先行定义完成的样子TDD 提供了一种判断工作完成的方式测试通过。但单元测试只验证某个函数符合预期而验收测试有时也叫 feature test描述的是整个功能是否完整可用——例如用户点击按钮后看到完整列表。先把验收测试写出来再用若干单元测试逐层逼近它这就是文档引用的 Nat Pryce 与 Steve Freeman 的Outside-in由外向内TDD 反馈环思想。我们的第一个验收测试如下projectpath是占位符需替换为你的模块路径加/clockfacepackage 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) } }这里用到 Go 一个特殊约定clockface_test.go放在与包同名的clockface目录下声明package clockface_test——一个可以与被测包文件共处一室的外部测试包。由于 SVG 从左上角开始计算坐标午夜时分秒针应垂直指向上方X 保持 150 不变Y 为 150 减去秒针长度 90即 60。运行测试得到预期失败--- FAIL: TestSecondHandAtMidnight (0.00s) ./clockface_test.go:13:10: undefined: clockface.Point ./clockface_test.go:14:9: undefined: clockface.SecondHand于是我们补齐类型骨架对应仓库 math/v1/clockface/clockface.go 的雏形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{} }再次运行得到Got {0 0}, wanted {150 60}此时用最粗暴的实现让它通过return Point{150, 60}。测试通过但显然还需要继续——毕竟不能所有时刻都显示午夜。思考时间秒针的数学问题秒针每分钟经历相同的 60 个状态。例如 37 秒时它指向 12 点方向顺时针转过37/60圈的位置换算成角度为(360/60) * 37 222度。但角度只是故事的一半我们还需要知道指针尖端对应的 X、Y 坐标。单位圆一切三角函数的起点想象一个圆心在原点 (0,0)、半径为 1 的圆——单位圆unit circle因为半径恰为 1 个单位圆周上的每个点 (x, y) 与其在坐标轴上的投影构成直角三角形斜边恒为 1即半径。三角学告诉我们如果已知该点与原点连线相对x 轴正方向3 点钟方向的夹角a那么 x 坐标就是cos(a)y 坐标就是sin(a)关键转折我们要从12 点钟方向y 轴而不是 x 轴3 点钟方向开始测量角度因此需要交换坐标轴的角色——此时x sin(a)y cos(a)至此我们知道了两件事秒针的角度每秒走 1/60 圈以及由角度换算 X/Y 坐标的公式。接下来需要 Go 的math包。弧度、math包与秒针角度函数Go 的math.Cos要求参数是弧度而非角度一整圈是2π弧度而非 360 度。既然一整圈是2π弧度半圈就是π弧度——这成为我们测试的自然锚点。现在写秒针角度的单元测试放在package clockface内部函数暂不导出同时把之前的验收测试文件改名为clockface_acceptance_test.go并暂时注释掉避免干扰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 秒应使秒针停在半圈位置即π弧度。从undefined: secondsInRadians开始先返回0得到失败输出再硬编码math.Pi使其通过。随后扩展为表格驱动测试并引入两个辅助函数降低书写成本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) } }) } } 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) }simpleTime只保留我们关心的时分秒字段testName把时间格式化为数字钟样式HH:MM:SS作为子测试名称失败输出一目了然。推导实现1 秒 2π/60弧度约分后为π/30弧度乘以秒数即可func secondsInRadians(t time.Time) float64 { return float64(t.Second()) * (math.Pi / 30) }浮点数的诅咒一个惊人事实测试却给出诡异结果clockface_test.go:24: Wanted 3.141592653589793 radians, but got 3.1415926535897936浮点运算天生不精确。π/30除以 30 再乘以 30得到的数字已不再等于math.Pi。解决办法有二接受它——画时钟时误差小到可以忽略但浮点相等比较会失败重排算式——把先除后乘改为只做除法π / (30 / numberOfSeconds)即func secondsInRadians(t time.Time) float64 { return (math.Pi / (30 / (float64(t.Second())))) }测试通过。这正是文档强调的核心教训浮点相等判断必须谨慎必要时通过代数变形恢复精度或引入足够接近的比较。关于除零的题外话Go 中显式除以零会在编译期报错如10.0 / 0.0但编译器无法预测t.Second()这类运行时值。若运行10.0 / zero()其中zero()返回0.0会打印Inf正无穷而除以 Inf的结果趋于 0math.Pi / (30 / 0)实际得到 0 弧度——秒针指向 12 点恰好是合理行为。从弧度到坐标秒针的端点接下来只考虑单位圆半径 1让所有指针长度为 1数学最简单之后再做缩放。测试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) } }) } }30 秒时秒针指向正下方单位圆上的坐标为 (0, -1)。从空实现return Point{}到硬编码Point{0, -1}后加入 45 秒用例Point{-1, 0}正左方触发失败再写出真正实现func secondHandPoint(t time.Time) Point { angle : secondsInRadians(t) x : math.Sin(angle) y : math.Cos(angle) return Point{x, y} }因为从 12 点方向起算x 用Sin、y 用Cos。结果又一次被浮点数诅咒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}误差出现在第 16 位小数小到微不足道。文档给出了取舍可以用math/big的Rat有理数类型提升精度但目标是画 SVG不是登月所以选择定义粗略相等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) }只要 X、Y 各自相差小于1e-70.0000001即视为相等精度绰绰有余。在 math/v4/clockface/clockface_test.go 中可以对照该版本代码。让验收测试通过缩放、翻转、平移回到验收测试TestSecondHandAt30Seconds期望Point{X: 150, Y: 150 90}当前实现却返回{150 60}。要把单位向量转换为 SVG 上的点需要三步且顺序不能颠倒缩放scale乘以指针长度翻转flip沿 X 轴翻转 Y以适配 SVG 左上角为原点的坐标系平移translate加上圆心坐标 (150, 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 * 90, p.Y * 90} // scale p Point{p.X, -p.Y} // flip p Point{p.X 150, p.Y 150} // translate return p }重构时把魔法数字提取为常量并让命名自解释const secondHandLength 90 const clockCentreX 150 const clockCentreY 150验收测试通过秒针端点计算完成。画第一根针生成 SVG 的 main 程序有了坐标计算就可以生成真正的 SVG 了。在clockface包目录下再建一个同样名为clockface的子目录放package main目录结构如下|-- clockface | |-- main.go |-- clockface.go |-- clockface_acceptance_test.go |-- clockface_test.gomain.go把字符串按顺序写入os.Stdout导入路径需替换为你自己的模块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 x1150 y1150 x2%f y2%f stylefill:none;stroke:#f00;stroke-width:3px;/, p.X, p.Y) } const svgStart ?xml version1.0 encodingUTF-8 standaloneno? !DOCTYPE svg PUBLIC -//W3C//DTD SVG 1.1//EN http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd svg xmlnshttp://www.w3.org/2000/svg width100% height100% viewBox0 0 300 300 version2.0 const bezel circle cx150 cy150 r100 stylefill:#fff;stroke:#000;stroke-width:5px;/ const svgEnd /svg构建并运行把输出重定向到文件go build ./clockface clock.svg即可得到一张只有秒针的时钟图。这一版对应仓库 math/v6/clockface。反思这段代码的异味文档坦诚地指出这版代码的两大问题SecondHand函数虽然强绑定 SVG长度、圆心、Y 翻转全是 SVG 专属约定却只字不提 SVG、也不产出 SVG没有任何测试覆盖 SVG 生成代码。第一版补救方案是断言输出字符串中包含特定标签片段如x2150 y260但文档否定了它字符串包含检查既不保证输出是合法 SVG太弱又会在加一个空格之类的无关改动时破裂太脆。用字符串表示来断言 XML 数据结构永远不是好主意。正确的验收测试把输出解析为 XML唯一的正解是把输出当作 XML 解析后再断言。Go 标准库encoding/xml的xml.Unmarshal接受[]byte与一个结构体指针。手工写结构体麻烦但把 SVG 样例粘贴到 xml-to-go 这类转换工具即可生成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 }然后创建SVGWriter函数写入任何io.Writer测试就能用bytes.Buffer接住并写出解析式验收测试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()) }运行发现%f默认打印 6 位小数产生x2150.000000与期望不符。把格式串改为%.3f保留 3 位小数并把期望更新为150.000/60.000测试通过fmt.Fprintf(w, line x1150 y1150 x2%.3f y2%.3f stylefill:none;stroke:#f00;stroke-width:3px;/, p.X, p.Y)main因此大幅瘦身func main() { t : time.Now() clockface.SVGWriter(os.Stdout, t) }让结构体更锋利粗版本的结构体还有三处可改进没覆盖x1等属性、数值属性不该是字符串、style与空Text节点无关紧要。改进后的结构体用命名类型、float64数值属性和精确字段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 }测试随之可以精确断言整条线段want : Line{150, 150, 150, 60} for _, line : range svg.Line { if line want { return } }再配合containsLine(line Line, lines []Line) bool辅助函数与simpleTime/testName把验收测试升级为表格驱动。注意clockface_acceptance_test.go位于package clockface_test与package clockface内部的clockface_test.go是不同包未导出的辅助函数不可见因此需要在验收测试文件里各自维护一份simpleTime与testName。这一版对应 math/v7c/clockface。分针连续运动与函数复用用同样的节奏为分针写测试先注释掉相关验收测试从低层函数做起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))}, } // ... }关键设计决策分针不应只在整分钟时跳变而应随每一秒微小前进。推导60 秒为 1 分钟、30 分钟为半圈π弧度因此半圈内有30 * 60秒若当前过了 7 秒分针应位于7 * (math.Pi / (30 * 60))弧度处。实现时直接复用已有的secondsInRadians——分针每秒只走秒针角度的 1/60func minutesInRadians(t time.Time) float64 { return (secondsInRadians(t) / 60) (math.Pi / (30 / float64(t.Minute()))) }minuteHandPoint与secondHandPoint高度雷同文档坦言是复制粘贴出来的于是 DRY 提取公共函数angleToPointfunc angleToPoint(angle float64) Point { x : math.Sin(angle) y : math.Cos(angle) return Point{x, y} } func minuteHandPoint(t time.Time) Point { return angleToPoint(minutesInRadians(t)) } func secondHandPoint(t time.Time) Point { return angleToPoint(secondsInRadians(t)) }画分针时再次出现 scale/flip/translate 三连击顺势抽出makeHand统一处理并声明minuteHandLength 80func secondHand(w io.Writer, t time.Time) { p : makeHand(secondHandPoint(t), secondHandLength) fmt.Fprintf(w, line x1150 y1150 x2%.3f y2%.3f stylefill: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 x1150 y1150 x2%.3f y2%.3f stylefill: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} }SVGWriter依次写入秒针与分针分针验收测试Line{150, 150, 150, 70}午夜分针指向上方150 减 80通过。此阶段对应 math/v9/clockface。时针12 小时制、取模与恐惧变无聊的 TDD 哲学时针的验收测试6 点整指针向下func TestSVGWriterHourHand(t *testing.T) { cases : []struct { time time.Time line Line }{ { simpleTime(6, 0, 0), Line{150, 150, 150, 200}, }, } // ... }从hoursInRadians起步6 点 π弧度func hoursInRadians(t time.Time) float64 { return (math.Pi / (6 / float64(t.Hour()))) }加入 21 点用例后失败10.995...而非4.712...因为这不是 24 小时制——必须用取模运算换算为 12 小时制func hoursInRadians(t time.Time) float64 { return (math.Pi / (6 / (float64(t.Hour() % 12)))) }随后要求时针也随分钟/秒数平滑移动。半圈对分针是 30 分钟对时针则是 6 小时分针角度已由minutesInRadians给出时针只需取其 1/12func hoursInRadians(t time.Time) float64 { return (minutesInRadians(t) / 12) (math.Pi / (6 / float64(t.Hour()%12))) }又遇浮点误差0.013089969389957472vs0.01308996938995747这次直接把所有弧度测试统一改用roughlyEqualFloat64断言一劳永逸对应 math/v10/clockface。hourHandPoint直接一行实现复用angleToPoint文档借此阐述了对 TDD 的态度TDD 不是宗教而是工具。当路径已走过多次、实现已了然于胸时可以跳过形式化的微小步骤但测试仍然先写——只是以更大的粒度呈现。文档引用 Kent Beck 的话Write tests until fear is transformed into boredom写到恐惧变成无聊为止——当你对该函数已无恐惧就可以继续前进了。最后补上时针的绘制hourHandLength 50午夜时指针向上Y 为150 - 50 1006 点时150 50 200验收测试通过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 x1150 y1150 x2%.3f y2%.3f stylefill:none;stroke:#000;stroke-width:3px;/, p.X, p.Y) }让魔法数字说出自己的含义clockface.go里散落着基于半圈有多少秒/分/时的魔法数字把它们提取为语义化常量const ( secondsInHalfClock 30 secondsInClock 2 * secondsInHalfClock minutesInHalfClock 30 minutesInClock 2 * minutesInHalfClock hoursInHalfClock 6 hoursInClock 2 * hoursInHalfClock )这样做有两个好处显式表达每个数字的含义未来维护时秒懂若想造4 小时一圈、20 秒一圈的怪钟这些常量随时可以升级为参数——留了一扇门。完整版见 math/v12/clockface/clockface.go。收官程序与库的分离以及最有价值的测试一个程序也是一个库我们写的代码其实解决了一类更普遍的问题时钟指针的角度与向量计算。因为 TDD 迫使我们把每个小问题隔离思考、并用函数固化隔离clockface 已经是一个相当不错的 API。文档引用 Henry Spencer 在《Unix 编程艺术》中的话API 应该随程序一起发布反之亦然——只给 C 程序用的 API 难学难用只有命令行形态的接口同样痛苦。在 math/vFinal/clockface 中文档展示了最终形态把原先未导出的角度/单位向量函数全部导出为公共 APISecondsInRadians、SecondHandPoint、MinutesInRadians、MinuteHandPoint、HoursInRadians、HourHandPoint见 math/vFinal/clockface/clockface.go每个函数都带包级文档注释SVG 生成部分拆分为独立包svg提供Write(w io.Writer, t time.Time)见 math/vFinal/clockface/svg/svg.go主程序只剩三行取当前时间调用svg.Write(os.Stdout, t)见 math/vFinal/clockface/clockface/main.go。配套测试也相应分为两层clockface_test.go覆盖角度与单位向量的纯计算math/vFinal/clockface/clockface_test.gosvg/svg_test.go覆盖 XML 解析式验收测试math/vFinal/clockface/svg/svg_test.go。为什么说测试可能是代码库中最有价值的资产你可能已经注意到最复杂的 SVG 处理逻辑不在业务代码里而在测试代码里——测试导入了 XML 库、解析 XML、精心重构结构体。这让人隐隐不安是不是应该在生产代码里用text/template模板、XML 库或某个 SVG 库文档的回答很坚定不必。因为怎么how生成 SVG 并不重要重要的是产出什么what——一张合法的 SVG。于是系统中最了解 SVG 的那部分恰恰应该是测试它必须有足够的上下文与严格性才能让我们确信输出的确实是 SVG。SVG 的what住在测试里how住在代码里。这段测试代码的价值甚至可能高于当前的生产代码——无论未来用什么方式生成 SVG它都能保证输出永远是合法 SVG。测试不是二等公民不是一次性代码。一份好测试的生命周期远超它测试的那版代码。永远不要觉得花太多时间写测试——这是投资。版本演进一览文档与仓库 math 目录一一对应适合对照研读版本里程碑关键文件v1定义Point类型与SecondHand骨架首个验收测试math/v1/clockface/clockface.gov2–v4弧度计算、单位向量、粗略相等断言math/v4/clockface/clockface_test.gov6生成只有秒针的 SVG字符串拼接版math/v6/clockfacev7b–v7cSVGWriterencoding/xml解析式验收测试math/v7c/clockfacev8–v10分针、时针、角度常量与精确断言math/v10/clockfacev12完整三针时钟与语义化常量math/v12/clockface/clockface.govFinal库/程序分离导出公共 API独立svg包math/vFinal/clockface小结这条从零到一张完整 SVG 模拟时钟的路径浓缩了 Go 实战中的几个核心素养用验收测试定义完成、由外向内逐层 TDD用单位圆与math.Sin/math.Cos做几何换算、并牢记 SVG 左上角原点带来的翻转问题以代数变形或粗略相等优雅应对浮点精度以及最重要的——用解析 XML 的方式测试 XML 输出让测试成为输出合法性的最终守门人。如果你想亲手跑一遍在对应版本目录下执行go test ./...即可验证全部单元测试与验收测试。赞分享教程示例工程【免费下载链接】learn-go-with-testsLearn Go with test-driven development项目地址https://gitcode.com/gh_mirrors/le/learn-go-with-tests点击查看免费下载相关推荐OpenCore Legacy Patcher终极指南30分钟让老旧Mac焕发新生OpenCore Legacy Patcher终极指南30分钟让老旧Mac焕发新生 OpenCore Legacy Patcher是一款革命性的开源工具专门操作系统固件驱动开发如何永久保存微信聊天记录WeChatMsg完整解决方案指南如何永久保存微信聊天记录WeChatMsg完整解决方案指南 你是否担心手机更换或丢失会导致珍贵的微信聊天记录永远消失那些与家人的温馨对话、重要的工作讨论、朋从SVG到3D建模svg-mesh-3d项目实战指南从SVG到3D建模svg mesh 3d项目实战指南 svg mesh 3d是一个将SVG路径字符串转换为3D三角网格的高阶模块特别适合处理轮廓型SVG图形上一篇Universal Android Debloater免 Root 给手机减负误操作了还能恢复下一篇告别键盘遮挡iOS开发者必备的智能输入框适配方案创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表