番茄钟功能测试与界面构建
1. 番茄钟业务逻辑测试
1.1 准备工作
在为番茄钟业务逻辑编写测试之前,我们需要创建一个辅助函数
getRepo
来获取存储库实例。以下是具体步骤:
1. 切换到
pomodoro
包目录:
$ cd $HOME/pragprog.com/rggo/interactiveTools/pomo/pomodoro
-
创建
inmemory_test.go文件,并添加以下内容:
package pomodoro_test
import (
"testing"
"pragprog.com/rggo/interactiveTools/pomo/pomodoro"
"pragprog.com/rggo/interactiveTools/pomo/pomodoro/repository"
)
func getRepo(t *testing.T) (pomodoro.Repository, func()) {
t.Helper()
return repository.NewInMemoryRepo(), func() {}
}
1.2 测试
NewConfig
函数
我们使用表驱动测试方法来测试
NewConfig
函数,包含三个测试用例:
Default
、
SingleInput
和
MultiInput
。以下是测试代码:
package pomodoro_test
import (
"context"
"errors"
"fmt"
"testing"
"time"
"pragprog.com/rggo/interactiveTools/pomo/pomodoro"
)
func TestNewConfig(t *testing.T) {
testCases := []struct {
name string
input [3]time.Duration
expect pomodoro.IntervalConfig
}{
{name: "Default",
expect: pomodoro.IntervalConfig{
PomodoroDuration: 25 * time.Minute,
ShortBreakDuration: 5 * time.Minute,
LongBreakDuration: 15 * time.Minute,
},
},
{name: "SingleInput",
input: [3]time.Duration{
20 * time.Minute,
},
expect: pomodoro.IntervalConfig{
PomodoroDuration: 20 * time.Minute,
ShortBreakDuration: 5 * time.Minute,
LongBreakDuration: 15 * time.Minute,
},
},
{name: "MultiInput",
input: [3]time.Duration{
20 * time.Minute,
10 * time.Minute,
12 * time.Minute,
},
expect: pomodoro.IntervalConfig{
PomodoroDuration: 20 * time.Minute,
ShortBreakDuration: 10 * time.Minute,
LongBreakDuration: 12 * time.Minute,
},
},
}
// Execute tests for NewConfig
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var repo pomodoro.Repository
config := pomodoro.NewConfig(
repo,
tc.input[0],
tc.input[1],
tc.input[2],
)
if config.PomodoroDuration != tc.expect.PomodoroDuration {
t.Errorf("Expected Pomodoro Duration %q, got %q instead\n",
tc.expect.PomodoroDuration, config.PomodoroDuration)
}
if config.ShortBreakDuration != tc.expect.ShortBreakDuration {
t.Errorf("Expected ShortBreak Duration %q, got %q instead\n",
tc.expect.ShortBreakDuration, config.ShortBreakDuration)
}
if config.LongBreakDuration != tc.expect.LongBreakDuration {
t.Errorf("Expected LongBreak Duration %q, got %q instead\n",
tc.expect.LongBreakDuration, config.LongBreakDuration)
}
})
}
}
1.3 测试
GetInterval
函数
为了测试
GetInterval
函数,我们需要执行该函数 16 次,以确保它能正确获取具有适当类别的间隔。以下是测试代码:
func TestGetInterval(t *testing.T) {
repo, cleanup := getRepo(t)
defer cleanup()
const duration = 1 * time.Millisecond
config := pomodoro.NewConfig(repo, 3*duration, duration, 2*duration)
for i := 1; i <= 16; i++ {
var (
expCategory string
expDuration time.Duration
)
switch {
case i%2 != 0:
expCategory = pomodoro.CategoryPomodoro
expDuration = 3 * duration
case i%8 == 0:
expCategory = pomodoro.CategoryLongBreak
expDuration = 2 * duration
case i%2 == 0:
expCategory = pomodoro.CategoryShortBreak
expDuration = duration
}
testName := fmt.Sprintf("%s%d", expCategory, i)
t.Run(testName, func(t *testing.T) {
res, err := pomodoro.GetInterval(config)
if err != nil {
t.Errorf("Expected no error, got %q.\n", err)
}
noop := func(pomodoro.Interval) {}
if err := res.Start(context.Background(), config,
noop, noop, noop); err != nil {
t.Fatal(err)
}
if res.Category != expCategory {
t.Errorf("Expected category %q, got %q.\n",
expCategory, res.Category)
}
if res.PlannedDuration != expDuration {
t.Errorf("Expected PlannedDuration %q, got %q.\n",
expDuration, res.PlannedDuration)
}
if res.State != pomodoro.StateNotStarted {
t.Errorf("Expected State = %q, got %q.\n",
pomodoro.StateNotStarted, res.State)
}
ui, err := repo.ByID(res.ID)
if err != nil {
t.Errorf("Expected no error. Got %q.\n", err)
}
if ui.State != pomodoro.StateDone {
t.Errorf("Expected State = %q, got %q.\n",
pomodoro.StateDone, res.State)
}
})
}
}
1.4 测试
Pause
方法
为了测试
Pause
方法,我们设置了两个测试用例:一个测试间隔未运行时的情况,另一个测试暂停正在运行的间隔。以下是测试代码:
func TestPause(t *testing.T) {
const duration = 2 * time.Second
repo, cleanup := getRepo(t)
defer cleanup()
config := pomodoro.NewConfig(repo, duration, duration, duration)
testCases := []struct {
name string
start bool
expState int
expDuration time.Duration
}{
{name: "NotStarted", start: false,
expState: pomodoro.StateNotStarted, expDuration: 0},
{name: "Paused", start: true,
expState: pomodoro.StatePaused, expDuration: duration / 2},
}
expError := pomodoro.ErrIntervalNotRunning
// Execute tests for Pause
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
i, err := pomodoro.GetInterval(config)
if err != nil {
t.Fatal(err)
}
start := func(pomodoro.Interval) {}
end := func(pomodoro.Interval) {
t.Errorf("End callback should not be executed")
}
periodic := func(i pomodoro.Interval) {
if err := i.Pause(config); err != nil {
t.Fatal(err)
}
}
if tc.start {
if err := i.Start(ctx, config, start, periodic, end); err != nil {
t.Fatal(err)
}
}
i, err = pomodoro.GetInterval(config)
if err != nil {
t.Fatal(err)
}
err = i.Pause(config)
if err != nil {
if !errors.Is(err, expError) {
t.Fatalf("Expected error %q, got %q", expError, err)
}
}
if err == nil {
t.Errorf("Expected error %q, got nil", expError)
}
i, err = repo.ByID(i.ID)
if err != nil {
t.Fatal(err)
}
if i.State != tc.expState {
t.Errorf("Expected state %d, got %d.\n",
tc.expState, i.State)
}
if i.ActualDuration != tc.expDuration {
t.Errorf("Expected duration %q, got %q.\n",
tc.expDuration, i.ActualDuration)
}
cancel()
})
}
}
1.5 测试
Start
方法
为了测试
Start
方法,我们设置了两个测试用例:一个测试间隔正常完成的情况,另一个测试在中间取消运行的情况。以下是测试代码:
func TestStart(t *testing.T) {
const duration = 2 * time.Second
repo, cleanup := getRepo(t)
defer cleanup()
config := pomodoro.NewConfig(repo, duration, duration, duration)
testCases := []struct {
name string
cancel bool
expState int
expDuration time.Duration
}{
{name: "Finish", cancel: false,
expState: pomodoro.StateDone, expDuration: duration},
{name: "Cancel", cancel: true,
expState: pomodoro.StateCancelled, expDuration: duration / 2},
}
// Execute tests for Start
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
i, err := pomodoro.GetInterval(config)
if err != nil {
t.Fatal(err)
}
start := func(i pomodoro.Interval) {
if i.State != pomodoro.StateRunning {
t.Errorf("Expected state %d, got %d.\n",
pomodoro.StateRunning, i.State)
}
if i.ActualDuration >= i.PlannedDuration {
t.Errorf("Expected ActualDuration %q, less than Planned %q.\n",
i.ActualDuration, i.PlannedDuration)
}
}
end := func(i pomodoro.Interval) {
if i.State != tc.expState {
t.Errorf("Expected state %d, got %d.\n",
tc.expState, i.State)
}
if tc.cancel {
t.Errorf("End callback should not be executed")
}
}
periodic := func(i pomodoro.Interval) {
if i.State != pomodoro.StateRunning {
t.Errorf("Expected state %d, got %d.\n",
pomodoro.StateRunning, i.State)
}
if tc.cancel {
cancel()
}
}
if err := i.Start(ctx, config, start, periodic, end); err != nil {
t.Fatal(err)
}
i, err = repo.ByID(i.ID)
if err != nil {
t.Fatal(err)
}
if i.State != tc.expState {
t.Errorf("Expected state %d, got %d.\n",
tc.expState, i.State)
}
if i.ActualDuration != tc.expDuration {
t.Errorf("Expected ActualDuration %q, got %q.\n",
tc.expDuration, i.ActualDuration)
}
cancel()
})
}
}
1.6 执行测试
完成所有测试代码后,我们可以执行以下命令来运行测试:
$ go test -v .
如果所有测试都通过,说明番茄钟应用的业务逻辑已经准备好。
测试流程总结
| 步骤 | 操作 |
|---|---|
| 1 |
创建
inmemory_test.go
文件并定义
getRepo
函数
|
| 2 |
创建
interval_test.go
文件并添加测试函数
|
| 3 |
执行
go test -v .
命令运行测试
|
graph TD;
A[创建 inmemory_test.go 文件] --> B[定义 getRepo 函数];
B --> C[创建 interval_test.go 文件];
C --> D[添加测试函数];
D --> E[执行 go test -v . 命令];
E --> F{测试是否通过};
F -- 通过 --> G[业务逻辑准备好];
F -- 未通过 --> H[修改代码并重新测试];
2. 构建终端界面
2.1 选择界面库
为了创建番茄钟应用的终端界面,我们选择使用
Termdash
仪表盘库。它具有跨平台、活跃开发和丰富功能等优点。我们使用
Tcell
作为后端库。
2.2 指定依赖版本
为了确保示例代码正常工作,我们使用 Go 模块指定
Termdash
的版本为
v0.13.0
:
$ cd $HOME/pragprog.com/rggo/interactiveTools/pomo
$ go mod edit -require github.com/mum4k/termdash@v0.13.0
2.3 设计界面
我们将界面分为四个主要部分:
1.
Timer 部分
:以文本和图形甜甜圈的形式显示当前间隔剩余时间。
2.
Type 部分
:显示当前间隔的类型或类别。
3.
Info 部分
:显示相关的用户消息和状态。
4.
Buttons 部分
:显示两个按钮,分别用于开始和暂停间隔。
2.4 实现界面组件
2.4.1 创建
widgets.go
文件
package app
import (
"context"
"github.com/mum4k/termdash/cell"
"github.com/mum4k/termdash/widgets/donut"
"github.com/mum4k/termdash/widgets/segmentdisplay"
"github.com/mum4k/termdash/widgets/text"
)
type widgets struct {
donTimer *donut.Donut
disType *segmentdisplay.SegmentDisplay
txtInfo *text.Text
txtTimer *text.Text
updateDonTimer chan []int
updateTxtInfo chan string
updateTxtTimer chan string
updateTxtType chan string
}
func (w *widgets) update(timer []int, txtType, txtInfo, txtTimer string,
redrawCh chan<- bool) {
if txtInfo != "" {
w.updateTxtInfo <- txtInfo
}
if txtType != "" {
w.updateTxtType <- txtType
}
if txtTimer != "" {
w.updateTxtTimer <- txtTimer
}
if len(timer) > 0 {
w.updateDonTimer <- timer
}
redrawCh <- true
}
func newWidgets(ctx context.Context, errorCh chan<- error) (*widgets, error) {
w := &widgets{}
var err error
w.updateDonTimer = make(chan []int)
w.updateTxtType = make(chan string)
w.updateTxtInfo = make(chan string)
w.updateTxtTimer = make(chan string)
w.donTimer, err = newDonut(ctx, w.updateDonTimer, errorCh)
if err != nil {
return nil, err
}
w.disType, err = newSegmentDisplay(ctx, w.updateTxtType, errorCh)
if err != nil {
return nil, err
}
w.txtInfo, err = newText(ctx, w.updateTxtInfo, errorCh)
if err != nil {
return nil, err
}
w.txtTimer, err = newText(ctx, w.updateTxtTimer, errorCh)
if err != nil {
return nil, err
}
return w, nil
}
func newText(ctx context.Context, updateText <-chan string,
errorCh chan<- error) (*text.Text, error) {
txt, err := text.New()
if err != nil {
return nil, err
}
// Goroutine to update Text
go func() {
for {
select {
case t := <-updateText:
txt.Reset()
errorCh <- txt.Write(t)
case <-ctx.Done():
return
}
}
}()
return txt, nil
}
func newDonut(ctx context.Context, donUpdater <-chan []int,
errorCh chan<- error) (*donut.Donut, error) {
don, err := donut.New(
donut.Clockwise(),
donut.CellOpts(cell.FgColor(cell.ColorBlue)),
)
if err != nil {
return nil, err
}
go func() {
for {
select {
case d := <-donUpdater:
if d[0] <= d[1] {
errorCh <- don.Absolute(d[0], d[1])
}
case <-ctx.Done():
return
}
}
}()
return don, nil
}
func newSegmentDisplay(ctx context.Context, updateText <-chan string,
errorCh chan<- error) (*segmentdisplay.SegmentDisplay, error) {
sd, err := segmentdisplay.New()
if err != nil {
return nil, err
}
// Goroutine to update SegmentDisplay
go func() {
for {
select {
case t := <-updateText:
if t == "" {
t = " "
}
errorCh <- sd.Write([]*segmentdisplay.TextChunk{
segmentdisplay.NewChunk(t),
})
case <-ctx.Done():
return
}
}
}()
return sd, nil
}
2.4.2 创建
buttons.go
文件
package app
import (
"context"
"fmt"
"github.com/mum4k/termdash/cell"
"github.com/mum4k/termdash/widgets/button"
"pragprog.com/rggo/interactiveTools/pomo/pomodoro"
)
type buttonSet struct {
btStart *button.Button
btPause *button.Button
}
func newButtonSet(ctx context.Context, config *pomodoro.IntervalConfig,
w *widgets, redrawCh chan<- bool, errorCh chan<- error) (*buttonSet, error) {
startInterval := func() {
i, err := pomodoro.GetInterval(config)
errorCh <- err
start := func(i pomodoro.Interval) {
message := "Take a break"
if i.Category == pomodoro.CategoryPomodoro {
message = "Focus on your task"
}
w.update([]int{}, i.Category, message, "", redrawCh)
}
end := func(pomodoro.Interval) {
w.update([]int{}, "", "Nothing running...", "", redrawCh)
}
periodic := func(i pomodoro.Interval) {
w.update(
[]int{int(i.ActualDuration), int(i.PlannedDuration)},
"", "",
fmt.Sprint(i.PlannedDuration-i.ActualDuration),
redrawCh,
)
}
errorCh <- i.Start(ctx, config, start, periodic, end)
}
pauseInterval := func() {
i, err := pomodoro.GetInterval(config)
if err != nil {
errorCh <- err
return
}
if err := i.Pause(config); err != nil {
if err == pomodoro.ErrIntervalNotRunning {
return
}
errorCh <- err
return
}
w.update([]int{}, "", "Paused... press start to continue", "", redrawCh)
}
btStart, err := button.New("(s)tart", func() error {
go startInterval()
return nil
},
button.GlobalKey('s'),
button.WidthFor("(p)ause"),
button.Height(2),
)
if err != nil {
return nil, err
}
btPause, err := button.New("(p)ause", func() error {
go pauseInterval()
return nil
},
button.FillColor(cell.ColorNumber(220)),
button.GlobalKey('p'),
button.Height(2),
)
if err != nil {
return nil, err
}
return &buttonSet{btStart, btPause}, nil
}
界面构建流程总结
| 步骤 | 操作 |
|---|---|
| 1 |
指定
Termdash
版本为
v0.13.0
|
| 2 |
创建
app
子目录
|
| 3 |
创建
widgets.go
文件并实现主要界面组件
|
| 4 |
创建
buttons.go
文件并实现按钮组件
|
graph TD;
A[指定 Termdash 版本] --> B[创建 app 子目录];
B --> C[创建 widgets.go 文件];
C --> D[实现主要界面组件];
D --> E[创建 buttons.go 文件];
E --> F[实现按钮组件];
通过以上步骤,我们完成了番茄钟应用的业务逻辑测试和终端界面的初步构建。
3. 界面布局与功能集成
3.1 布局设计思路
在完成了界面组件的创建后,接下来需要将这些组件进行合理布局,以实现一个完整且易用的终端界面。我们要考虑各个组件的位置关系,确保用户能够清晰地看到每个组件所展示的信息,并且方便操作按钮。
3.2 集成界面组件
为了将之前创建的
widgets.go
和
buttons.go
中的组件集成到一个完整的界面中,我们需要创建一个新的文件,例如
main.go
。以下是一个简单的示例代码,展示了如何集成这些组件:
package main
import (
"context"
"errors"
"fmt"
"pragprog.com/rggo/interactiveTools/pomo/app"
"pragprog.com/rggo/interactiveTools/pomo/pomodoro"
"github.com/mum4k/termdash"
"github.com/mum4k/termdash/container"
"github.com/mum4k/termdash/linestyle"
"github.com/mum4k/termdash/terminal/tcell"
"github.com/mum4k/termdash/terminal/terminalapi"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// 创建终端实例
t, err := tcell.New()
if err != nil {
panic(err)
}
defer t.Close()
// 创建存储库
repo, cleanup := app.GetRepo()
defer cleanup()
// 创建配置
const duration = 2 * time.Second
config := pomodoro.NewConfig(repo, duration, duration, duration)
// 创建错误通道
errorCh := make(chan error)
// 创建界面组件
w, err := app.NewWidgets(ctx, errorCh)
if err != nil {
panic(err)
}
// 创建按钮组件
btns, err := app.NewButtonSet(ctx, &config, w, nil, errorCh)
if err != nil {
panic(err)
}
// 创建容器布局
c, err := container.New(
t,
container.Border(linestyle.Light),
container.BorderTitle("Pomodoro App"),
container.SplitHorizontal(
container.Top(
container.SplitVertical(
container.Left(
container.PlaceWidget(w.DonTimer),
),
container.Right(
container.PlaceWidget(w.TxtTimer),
),
container.SplitPercent(50),
),
),
container.Bottom(
container.SplitVertical(
container.Left(
container.PlaceWidget(w.DisType),
),
container.Right(
container.PlaceWidget(w.TxtInfo),
),
container.SplitPercent(50),
),
container.PlaceWidget(btns.BtStart),
container.PlaceWidget(btns.BtPause),
),
container.SplitPercent(70),
),
)
if err != nil {
panic(err)
}
// 启动错误处理协程
go func() {
for {
select {
case err := <-errorCh:
if err != nil {
fmt.Fprintf(t, "Error: %v\n", err)
cancel()
return
}
case <-ctx.Done():
return
}
}
}()
// 启动终端仪表盘
if err := termdash.Run(ctx, t, c); err != nil {
if errors.Is(err, context.Canceled) {
return
}
panic(err)
}
}
3.3 代码解释
-
终端实例创建
:使用
tcell.New()创建一个终端实例,用于显示界面。 - 存储库和配置创建 :创建存储库和番茄钟配置,为后续操作提供基础。
-
组件创建
:调用
app.NewWidgets()和app.NewButtonSet()分别创建界面组件和按钮组件。 -
容器布局
:使用
container.New()创建一个容器布局,将各个组件放置在合适的位置。通过container.SplitHorizontal()和container.SplitVertical()进行水平和垂直分割,实现组件的排列。 -
错误处理
:启动一个协程监听
errorCh通道,处理可能出现的错误。 -
终端仪表盘运行
:使用
termdash.Run()启动终端仪表盘,显示界面。
集成流程总结
| 步骤 | 操作 |
|---|---|
| 1 | 创建终端实例 |
| 2 | 创建存储库和配置 |
| 3 | 创建界面组件和按钮组件 |
| 4 | 设计容器布局 |
| 5 | 启动错误处理协程 |
| 6 | 运行终端仪表盘 |
graph TD;
A[创建终端实例] --> B[创建存储库和配置];
B --> C[创建界面组件];
C --> D[创建按钮组件];
D --> E[设计容器布局];
E --> F[启动错误处理协程];
F --> G[运行终端仪表盘];
4. 总结与展望
4.1 总结
通过以上一系列步骤,我们完成了番茄钟应用的业务逻辑测试和终端界面的构建。从编写测试用例确保业务逻辑的正确性,到使用
Termdash
库创建终端界面的各个组件,再到将这些组件集成到一个完整的界面中,我们逐步实现了一个功能完整的番茄钟应用。
4.2 展望
虽然目前的番茄钟应用已经具备了基本的功能,但仍有许多可以改进和扩展的地方。例如:
1.
功能扩展
:可以添加更多的功能,如统计功能,记录用户的番茄钟使用情况,生成统计报表;或者添加任务管理功能,让用户可以创建和管理任务。
2.
界面优化
:进一步优化界面的布局和样式,提高用户体验。可以添加更多的动画效果,让界面更加生动。
3.
跨平台兼容性
:虽然
Termdash
是跨平台的,但可以进行更多的测试,确保应用在不同的操作系统和终端上都能正常运行。
通过不断的改进和扩展,我们可以让番茄钟应用更加完善,满足用户的更多需求。
超级会员免费看
29

被折叠的 条评论
为什么被折叠?



