diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6c929d4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.golden -text diff --git a/.golangci.yaml b/.golangci.yaml index 4463d5c..5a24714 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -136,7 +136,7 @@ linters-settings: nolintlint: # Exclude following linters from requiring an explanation. # Default: [] - allow-no-explanation: [ funlen, gocognit, lll ] + allow-no-explanation: [funlen, gocognit, lll] # Enable to require an explanation of nonzero length after each nolint directive. # Default: false require-explanation: true @@ -179,12 +179,9 @@ linters-settings: # Default: false all: true - linters: disable-all: true enable: - # TODO: enable the following over time - # - testpackage # makes you use a separate _test package ## enabled by default - errcheck # checking for unchecked errors, these unchecked errors can be critical bugs in some cases - gosimple # specializes in simplifying a code @@ -297,7 +294,6 @@ linters: #- thelper # detects golang test helpers without t.Helper() call and checks the consistency of test helpers #- wsl # [too strict and mostly code is not more readable] whitespace linter forces you to use empty lines - issues: # Maximum count of issues with the same text. # Set to 0 to disable. @@ -306,9 +302,9 @@ issues: exclude-rules: - source: "(noinspection|TODO)" - linters: [ godot ] + linters: [godot] - source: "//noinspection" - linters: [ gocritic ] + linters: [gocritic] - path: "_test\\.go" linters: - bodyclose @@ -317,4 +313,4 @@ issues: - goconst - gosec - noctx - - wrapcheck \ No newline at end of file + - wrapcheck diff --git a/.mockery.yaml b/.mockery.yaml new file mode 100644 index 0000000..0d87d8d --- /dev/null +++ b/.mockery.yaml @@ -0,0 +1,9 @@ +with-expecter: true +inpackage: true +dir: "{{.InterfaceDir}}" +outpkg: "{{.PackageName}}" + +packages: + github.com/Broderick-Westrope/tetrigo/internal/tui/components: + interfaces: + Stopwatch: {} diff --git a/Taskfile.yaml b/Taskfile.yaml index c05e2b6..21e3095 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -1,12 +1,13 @@ -version: '3' +version: "3" env: - GOLANG_CROSS_VERSION: 'v1.22' - PACKAGE_NAME: 'github.com/Broderick-Westrope/tetrigo' + GOLANG_CROSS_VERSION: "v1.22" + PACKAGE_NAME: "github.com/Broderick-Westrope/tetrigo" tasks: default: cmds: + - task: codegen - task: lint - task: test @@ -14,7 +15,7 @@ tasks: desc: Installs Tetrigo aliases: [i] sources: - - './**/*.go' + - "./**/*.go" cmds: - go install -v ./cmd/tetrigo @@ -24,11 +25,16 @@ tasks: - go mod download - go mod tidy + codegen: + desc: Generate code dependencies + cmds: + - mockery + lint: desc: Runs golangci-lint aliases: [l] sources: - - './**/*.go' + - "./**/*.go" - .golangci.yaml cmds: - golangci-lint run @@ -36,7 +42,7 @@ tasks: lint:fix: desc: Runs golangci-lint and fixes any issues sources: - - './**/*.go' + - "./**/*.go" - .golangci.yaml cmds: - golangci-lint run --fix @@ -76,4 +82,4 @@ tasks: goreleaser:release: desc: Releases the project cmds: - - bash ./scripts/goreleaser/release.sh \ No newline at end of file + - bash ./scripts/goreleaser/release.sh diff --git a/go.mod b/go.mod index 4ffefe8..771a340 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.23.3 require ( github.com/Broderick-Westrope/charmutils v0.0.0-20241115050827-f328b6667400 + github.com/Broderick-Westrope/x/exp/teatest v0.0.0-20241225091642-1c6e3bb330ba github.com/BurntSushi/toml v1.4.0 github.com/adrg/xdg v0.5.3 github.com/alecthomas/kong v1.4.0 @@ -18,13 +19,17 @@ require ( require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymanbagabas/go-udiff v0.2.0 // indirect github.com/catppuccin/go v0.2.0 // indirect github.com/charmbracelet/x/ansi v0.4.5 // indirect + github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20241113152101-0af7d04e9f32 // indirect + github.com/charmbracelet/x/exp/teatest v0.0.0-20241222104055-e1130b311607 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/kr/pretty v0.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -35,8 +40,11 @@ require ( github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/rogpeppe/go-internal v1.8.1 // indirect + github.com/stretchr/objx v0.5.2 // indirect golang.org/x/sync v0.9.0 // indirect golang.org/x/sys v0.27.0 // indirect golang.org/x/text v0.20.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index f2d8c68..d9597f6 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/Broderick-Westrope/charmutils v0.0.0-20241115050827-f328b6667400 h1:cb9Y/X/7+JemuYjw/Fv2ELVgtd6BwIiB82EMbmm7wGs= github.com/Broderick-Westrope/charmutils v0.0.0-20241115050827-f328b6667400/go.mod h1:oh2FSHkUk/5karkxreTzft6smriW821P7puhvnrOMtw= +github.com/Broderick-Westrope/x/exp/teatest v0.0.0-20241225091642-1c6e3bb330ba h1:NtGVUvXNWt25u4tbNwZxkCZOsImEJ+x82eNXWdZTba8= +github.com/Broderick-Westrope/x/exp/teatest v0.0.0-20241225091642-1c6e3bb330ba/go.mod h1:M39MtESS/g+wP2Njvdjgj5rykE7Di1Ms/7ItsJ8A3+g= github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= @@ -34,8 +36,11 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAM github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/strings v0.0.0-20241113152101-0af7d04e9f32 h1:YGyLdtHqkyqlKe1bPsDfI+5Dl1SToZ97+F2Nv34GKAY= github.com/charmbracelet/x/exp/strings v0.0.0-20241113152101-0af7d04e9f32/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/exp/teatest v0.0.0-20241222104055-e1130b311607 h1:nKQq4Yrr4WxsM5SDXahuEByNH2ncW6at+LV3uH4wzEk= +github.com/charmbracelet/x/exp/teatest v0.0.0-20241222104055-e1130b311607/go.mod h1:ag+SpTUkiN/UuUGYPX3Ci4fR1oF3XX97PpGhiXK7i6U= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -44,6 +49,14 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -62,11 +75,17 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg= github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= +github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= @@ -77,7 +96,10 @@ golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go index 0ff4927..cb60700 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,8 +39,8 @@ func GetConfig(path string) (*Config, error) { MaxLevel: 15, EndOnMaxLevel: false, - Theme: defaultTheme(), - Keys: defaultKeys(), + Theme: DefaultTheme(), + Keys: DefaultKeys(), } _, err := toml.DecodeFile(path, &c) diff --git a/internal/config/keys.go b/internal/config/keys.go index d3d3dc8..3ff4300 100644 --- a/internal/config/keys.go +++ b/internal/config/keys.go @@ -13,7 +13,7 @@ type Keys struct { RotateClockwise []string `toml:"rotate_clockwise"` } -func defaultKeys() *Keys { +func DefaultKeys() *Keys { return &Keys{ ForceQuit: []string{"ctrl+c"}, Exit: []string{"esc"}, diff --git a/internal/config/theme.go b/internal/config/theme.go index 06e9ce7..4f9599f 100644 --- a/internal/config/theme.go +++ b/internal/config/theme.go @@ -21,7 +21,7 @@ type Theme struct { } `toml:"characters"` } -func defaultTheme() *Theme { +func DefaultTheme() *Theme { theme := new(Theme) theme.Colours.TetriminoCells.I = "#64C4EB" diff --git a/internal/data/data.go b/internal/data/data.go index eac9189..86a5e1b 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -13,7 +13,7 @@ func NewDB(dataSourceName string) (*sql.DB, error) { return nil, err } - err = ensureTablesExist(db) + err = EnsureTablesExist(db) if err != nil { return nil, err } @@ -21,7 +21,7 @@ func NewDB(dataSourceName string) (*sql.DB, error) { return db, nil } -func ensureTablesExist(db *sql.DB) error { +func EnsureTablesExist(db *sql.DB) error { // Leaderboard table _, err := db.Exec( `CREATE TABLE IF NOT EXISTS leaderboard diff --git a/internal/tui/components/mock_Stopwatch.go b/internal/tui/components/mock_Stopwatch.go new file mode 100644 index 0000000..4ea4f02 --- /dev/null +++ b/internal/tui/components/mock_Stopwatch.go @@ -0,0 +1,453 @@ +// Code generated by mockery v2.50.1. DO NOT EDIT. + +package components + +import ( + tea "github.com/charmbracelet/bubbletea" + mock "github.com/stretchr/testify/mock" + + time "time" +) + +// MockStopwatch is an autogenerated mock type for the Stopwatch type +type MockStopwatch struct { + mock.Mock +} + +type MockStopwatch_Expecter struct { + mock *mock.Mock +} + +func (_m *MockStopwatch) EXPECT() *MockStopwatch_Expecter { + return &MockStopwatch_Expecter{mock: &_m.Mock} +} + +// Elapsed provides a mock function with no fields +func (_m *MockStopwatch) Elapsed() time.Duration { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Elapsed") + } + + var r0 time.Duration + if rf, ok := ret.Get(0).(func() time.Duration); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(time.Duration) + } + + return r0 +} + +// MockStopwatch_Elapsed_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Elapsed' +type MockStopwatch_Elapsed_Call struct { + *mock.Call +} + +// Elapsed is a helper method to define mock.On call +func (_e *MockStopwatch_Expecter) Elapsed() *MockStopwatch_Elapsed_Call { + return &MockStopwatch_Elapsed_Call{Call: _e.mock.On("Elapsed")} +} + +func (_c *MockStopwatch_Elapsed_Call) Run(run func()) *MockStopwatch_Elapsed_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockStopwatch_Elapsed_Call) Return(_a0 time.Duration) *MockStopwatch_Elapsed_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockStopwatch_Elapsed_Call) RunAndReturn(run func() time.Duration) *MockStopwatch_Elapsed_Call { + _c.Call.Return(run) + return _c +} + +// ID provides a mock function with no fields +func (_m *MockStopwatch) ID() int { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for ID") + } + + var r0 int + if rf, ok := ret.Get(0).(func() int); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(int) + } + + return r0 +} + +// MockStopwatch_ID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ID' +type MockStopwatch_ID_Call struct { + *mock.Call +} + +// ID is a helper method to define mock.On call +func (_e *MockStopwatch_Expecter) ID() *MockStopwatch_ID_Call { + return &MockStopwatch_ID_Call{Call: _e.mock.On("ID")} +} + +func (_c *MockStopwatch_ID_Call) Run(run func()) *MockStopwatch_ID_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockStopwatch_ID_Call) Return(_a0 int) *MockStopwatch_ID_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockStopwatch_ID_Call) RunAndReturn(run func() int) *MockStopwatch_ID_Call { + _c.Call.Return(run) + return _c +} + +// Init provides a mock function with no fields +func (_m *MockStopwatch) Init() tea.Cmd { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Init") + } + + var r0 tea.Cmd + if rf, ok := ret.Get(0).(func() tea.Cmd); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(tea.Cmd) + } + } + + return r0 +} + +// MockStopwatch_Init_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Init' +type MockStopwatch_Init_Call struct { + *mock.Call +} + +// Init is a helper method to define mock.On call +func (_e *MockStopwatch_Expecter) Init() *MockStopwatch_Init_Call { + return &MockStopwatch_Init_Call{Call: _e.mock.On("Init")} +} + +func (_c *MockStopwatch_Init_Call) Run(run func()) *MockStopwatch_Init_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockStopwatch_Init_Call) Return(_a0 tea.Cmd) *MockStopwatch_Init_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockStopwatch_Init_Call) RunAndReturn(run func() tea.Cmd) *MockStopwatch_Init_Call { + _c.Call.Return(run) + return _c +} + +// Reset provides a mock function with no fields +func (_m *MockStopwatch) Reset() tea.Cmd { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Reset") + } + + var r0 tea.Cmd + if rf, ok := ret.Get(0).(func() tea.Cmd); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(tea.Cmd) + } + } + + return r0 +} + +// MockStopwatch_Reset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reset' +type MockStopwatch_Reset_Call struct { + *mock.Call +} + +// Reset is a helper method to define mock.On call +func (_e *MockStopwatch_Expecter) Reset() *MockStopwatch_Reset_Call { + return &MockStopwatch_Reset_Call{Call: _e.mock.On("Reset")} +} + +func (_c *MockStopwatch_Reset_Call) Run(run func()) *MockStopwatch_Reset_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockStopwatch_Reset_Call) Return(_a0 tea.Cmd) *MockStopwatch_Reset_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockStopwatch_Reset_Call) RunAndReturn(run func() tea.Cmd) *MockStopwatch_Reset_Call { + _c.Call.Return(run) + return _c +} + +// SetInterval provides a mock function with given fields: _a0 +func (_m *MockStopwatch) SetInterval(_a0 time.Duration) { + _m.Called(_a0) +} + +// MockStopwatch_SetInterval_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetInterval' +type MockStopwatch_SetInterval_Call struct { + *mock.Call +} + +// SetInterval is a helper method to define mock.On call +// - _a0 time.Duration +func (_e *MockStopwatch_Expecter) SetInterval(_a0 interface{}) *MockStopwatch_SetInterval_Call { + return &MockStopwatch_SetInterval_Call{Call: _e.mock.On("SetInterval", _a0)} +} + +func (_c *MockStopwatch_SetInterval_Call) Run(run func(_a0 time.Duration)) *MockStopwatch_SetInterval_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(time.Duration)) + }) + return _c +} + +func (_c *MockStopwatch_SetInterval_Call) Return() *MockStopwatch_SetInterval_Call { + _c.Call.Return() + return _c +} + +func (_c *MockStopwatch_SetInterval_Call) RunAndReturn(run func(time.Duration)) *MockStopwatch_SetInterval_Call { + _c.Run(run) + return _c +} + +// Stop provides a mock function with no fields +func (_m *MockStopwatch) Stop() tea.Cmd { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Stop") + } + + var r0 tea.Cmd + if rf, ok := ret.Get(0).(func() tea.Cmd); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(tea.Cmd) + } + } + + return r0 +} + +// MockStopwatch_Stop_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Stop' +type MockStopwatch_Stop_Call struct { + *mock.Call +} + +// Stop is a helper method to define mock.On call +func (_e *MockStopwatch_Expecter) Stop() *MockStopwatch_Stop_Call { + return &MockStopwatch_Stop_Call{Call: _e.mock.On("Stop")} +} + +func (_c *MockStopwatch_Stop_Call) Run(run func()) *MockStopwatch_Stop_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockStopwatch_Stop_Call) Return(_a0 tea.Cmd) *MockStopwatch_Stop_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockStopwatch_Stop_Call) RunAndReturn(run func() tea.Cmd) *MockStopwatch_Stop_Call { + _c.Call.Return(run) + return _c +} + +// Toggle provides a mock function with no fields +func (_m *MockStopwatch) Toggle() tea.Cmd { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Toggle") + } + + var r0 tea.Cmd + if rf, ok := ret.Get(0).(func() tea.Cmd); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(tea.Cmd) + } + } + + return r0 +} + +// MockStopwatch_Toggle_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Toggle' +type MockStopwatch_Toggle_Call struct { + *mock.Call +} + +// Toggle is a helper method to define mock.On call +func (_e *MockStopwatch_Expecter) Toggle() *MockStopwatch_Toggle_Call { + return &MockStopwatch_Toggle_Call{Call: _e.mock.On("Toggle")} +} + +func (_c *MockStopwatch_Toggle_Call) Run(run func()) *MockStopwatch_Toggle_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockStopwatch_Toggle_Call) Return(_a0 tea.Cmd) *MockStopwatch_Toggle_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockStopwatch_Toggle_Call) RunAndReturn(run func() tea.Cmd) *MockStopwatch_Toggle_Call { + _c.Call.Return(run) + return _c +} + +// Update provides a mock function with given fields: _a0 +func (_m *MockStopwatch) Update(_a0 tea.Msg) (tea.Model, tea.Cmd) { + ret := _m.Called(_a0) + + if len(ret) == 0 { + panic("no return value specified for Update") + } + + var r0 tea.Model + var r1 tea.Cmd + if rf, ok := ret.Get(0).(func(tea.Msg) (tea.Model, tea.Cmd)); ok { + return rf(_a0) + } + if rf, ok := ret.Get(0).(func(tea.Msg) tea.Model); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(tea.Model) + } + } + + if rf, ok := ret.Get(1).(func(tea.Msg) tea.Cmd); ok { + r1 = rf(_a0) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(tea.Cmd) + } + } + + return r0, r1 +} + +// MockStopwatch_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update' +type MockStopwatch_Update_Call struct { + *mock.Call +} + +// Update is a helper method to define mock.On call +// - _a0 tea.Msg +func (_e *MockStopwatch_Expecter) Update(_a0 interface{}) *MockStopwatch_Update_Call { + return &MockStopwatch_Update_Call{Call: _e.mock.On("Update", _a0)} +} + +func (_c *MockStopwatch_Update_Call) Run(run func(_a0 tea.Msg)) *MockStopwatch_Update_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(tea.Msg)) + }) + return _c +} + +func (_c *MockStopwatch_Update_Call) Return(_a0 tea.Model, _a1 tea.Cmd) *MockStopwatch_Update_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockStopwatch_Update_Call) RunAndReturn(run func(tea.Msg) (tea.Model, tea.Cmd)) *MockStopwatch_Update_Call { + _c.Call.Return(run) + return _c +} + +// View provides a mock function with no fields +func (_m *MockStopwatch) View() string { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for View") + } + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// MockStopwatch_View_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'View' +type MockStopwatch_View_Call struct { + *mock.Call +} + +// View is a helper method to define mock.On call +func (_e *MockStopwatch_Expecter) View() *MockStopwatch_View_Call { + return &MockStopwatch_View_Call{Call: _e.mock.On("View")} +} + +func (_c *MockStopwatch_View_Call) Run(run func()) *MockStopwatch_View_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockStopwatch_View_Call) Return(_a0 string) *MockStopwatch_View_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockStopwatch_View_Call) RunAndReturn(run func() string) *MockStopwatch_View_Call { + _c.Call.Return(run) + return _c +} + +// NewMockStopwatch creates a new instance of MockStopwatch. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockStopwatch(t interface { + mock.TestingT + Cleanup(func()) +}) *MockStopwatch { + mock := &MockStopwatch{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/tui/components/stopwatch.go b/internal/tui/components/stopwatch.go new file mode 100644 index 0000000..0f75833 --- /dev/null +++ b/internal/tui/components/stopwatch.go @@ -0,0 +1,68 @@ +package components + +import ( + "time" + + "github.com/charmbracelet/bubbles/stopwatch" + tea "github.com/charmbracelet/bubbletea" +) + +type Stopwatch interface { + Init() tea.Cmd + Update(tea.Msg) (tea.Model, tea.Cmd) + View() string + Elapsed() time.Duration + SetInterval(time.Duration) + ID() int + Reset() tea.Cmd + Toggle() tea.Cmd + Stop() tea.Cmd +} + +type stopwatchImpl struct { + model stopwatch.Model +} + +func NewStopwatchWithInterval(interval time.Duration) Stopwatch { + return &stopwatchImpl{ + stopwatch.NewWithInterval(interval), + } +} + +func (s *stopwatchImpl) Init() tea.Cmd { + return s.model.Init() +} + +func (s *stopwatchImpl) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + m, cmd := s.model.Update(msg) + s.model = m + return s, cmd +} + +func (s *stopwatchImpl) View() string { + return s.model.View() +} + +func (s *stopwatchImpl) Elapsed() time.Duration { + return s.model.Elapsed() +} + +func (s *stopwatchImpl) SetInterval(interval time.Duration) { + s.model.Interval = interval +} + +func (s *stopwatchImpl) ID() int { + return s.model.ID() +} + +func (s *stopwatchImpl) Reset() tea.Cmd { + return s.model.Reset() +} + +func (s *stopwatchImpl) Toggle() tea.Cmd { + return s.model.Toggle() +} + +func (s *stopwatchImpl) Stop() tea.Cmd { + return s.model.Stop() +} diff --git a/internal/tui/components/timer.go b/internal/tui/components/timer.go new file mode 100644 index 0000000..bf2320e --- /dev/null +++ b/internal/tui/components/timer.go @@ -0,0 +1,63 @@ +package components + +import ( + time "time" + + "github.com/charmbracelet/bubbles/timer" + tea "github.com/charmbracelet/bubbletea" +) + +type Timer interface { + Init() tea.Cmd + Update(tea.Msg) (tea.Model, tea.Cmd) + View() string + ID() int + GetTimeout() time.Duration + SetTimeout(time.Duration) + Stop() tea.Cmd + Toggle() tea.Cmd +} + +type timerImpl struct { + model timer.Model +} + +func NewTimerWithInterval(timeout, interval time.Duration) Timer { + return &timerImpl{ + model: timer.NewWithInterval(timeout, interval), + } +} + +func (t *timerImpl) Init() tea.Cmd { + return t.model.Init() +} + +func (t *timerImpl) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + m, cmd := t.model.Update(msg) + t.model = m + return t, cmd +} + +func (t *timerImpl) View() string { + return t.model.View() +} + +func (t *timerImpl) GetTimeout() time.Duration { + return t.model.Timeout +} + +func (t *timerImpl) ID() int { + return t.model.ID() +} + +func (t *timerImpl) SetTimeout(timeout time.Duration) { + t.model.Timeout = timeout +} + +func (t *timerImpl) Stop() tea.Cmd { + return t.model.Stop() +} + +func (t *timerImpl) Toggle() tea.Cmd { + return t.model.Toggle() +} diff --git a/internal/tui/testutils/testutils.go b/internal/tui/testutils/testutils.go new file mode 100644 index 0000000..c4e7b42 --- /dev/null +++ b/internal/tui/testutils/testutils.go @@ -0,0 +1,41 @@ +package testutils + +import ( + "database/sql" + "testing" + "time" + + "github.com/Broderick-Westrope/tetrigo/internal/data" + "github.com/Broderick-Westrope/x/exp/teatest" + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/require" +) + +func WaitForMsgOfType[T tea.Msg](t *testing.T, tm *teatest.TestModel, msgCh chan T, timeout time.Duration) { + msg := tm.WaitForMsg(t, func(msg tea.Msg) bool { + _, ok := msg.(T) + return ok + }, teatest.WithDuration(timeout)) + + if msg == nil { + t.Error("WaitForMsg returned nil") + return + } + + concreteMsg, ok := msg.(T) + if !ok { + var zeroValue T + t.Errorf("Expected %T, got %T", zeroValue, msg) + return + } + msgCh <- concreteMsg +} + +func SetupInMemoryDB(t *testing.T) *sql.DB { + db, err := sql.Open("sqlite3", ":memory:") + require.NoError(t, err) + + err = data.EnsureTablesExist(db) + require.NoError(t, err) + return db +} diff --git a/internal/tui/views/leaderboard_test.go b/internal/tui/views/leaderboard_test.go new file mode 100644 index 0000000..e6cd964 --- /dev/null +++ b/internal/tui/views/leaderboard_test.go @@ -0,0 +1,152 @@ +package views + +import ( + "fmt" + "testing" + "time" + + "github.com/Broderick-Westrope/tetrigo/internal/data" + "github.com/Broderick-Westrope/tetrigo/internal/tui" + "github.com/Broderick-Westrope/tetrigo/internal/tui/testutils" + "github.com/Broderick-Westrope/x/exp/teatest" + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/require" +) + +func TestLeaderboard_TableEntries(t *testing.T) { + db := testutils.SetupInMemoryDB(t) + repo := data.NewLeaderboardRepository(db) + + tt := map[string]struct { + count int + }{ + "0 (empty)": { + count: 0, + }, + "3 (partial)": { + count: 3, + }, + "50 (overfull)": { + count: 50, + }, + } + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + for i := range tc.count { + _, err := repo.Save(&data.Score{ + GameMode: t.Name(), + Name: fmt.Sprintf("user-%d", i), + Time: time.Second * time.Duration(i*2), + Score: i * 100, + Lines: i, + Level: i + 2, + }) + require.NoError(t, err) + } + + m, err := NewLeaderboardModel(&tui.LeaderboardInput{ + GameMode: t.Name(), + }, db) + require.NoError(t, err) + + tm := teatest.NewTestModel(t, m) + tm.Send(tea.Quit()) + + outBytes := []byte(tm.FinalModel(t).View()) + teatest.RequireEqualOutput(t, outBytes) + }) + } +} + +func TestLeaderboard_NewEntryInEmptyTable(t *testing.T) { + db := testutils.SetupInMemoryDB(t) + + m, err := NewLeaderboardModel(&tui.LeaderboardInput{ + GameMode: t.Name(), + NewEntry: &data.Score{ + GameMode: t.Name(), + Name: "user-new", + Time: time.Minute, + Score: 1000, + Lines: 2, + Level: 3, + }, + }, db) + require.NoError(t, err) + + tm := teatest.NewTestModel(t, m) + tm.Send(tea.Quit()) + + outBytes := []byte(tm.FinalModel(t).View()) + teatest.RequireEqualOutput(t, outBytes) +} + +func TestLeaderboard_KeyboardNavigation(t *testing.T) { + db := testutils.SetupInMemoryDB(t) + repo := data.NewLeaderboardRepository(db) + + for i := range 50 { + _, err := repo.Save(&data.Score{ + GameMode: t.Name(), + Name: fmt.Sprintf("user-%d", i), + Time: time.Second * time.Duration(i*2), + Score: i * 100, + Lines: i, + Level: i + 2, + }) + require.NoError(t, err) + } + + m, err := NewLeaderboardModel(&tui.LeaderboardInput{ + GameMode: t.Name(), + NewEntry: &data.Score{ + GameMode: t.Name(), + Name: "user-new", + Time: time.Minute, + Score: 2001, + Lines: 2, + Level: 3, + }, + }, db) + require.NoError(t, err) + + tm := teatest.NewTestModel(t, m) + for range 3 { + tm.Send(tea.KeyMsg{Type: tea.KeyDown}) + time.Sleep(10 * time.Millisecond) + } + tm.Send(tea.Quit()) + + outBytes := []byte(tm.FinalModel(t).View()) + teatest.RequireEqualOutput(t, outBytes) +} + +func TestLeaderboard_SwitchModeMsg(t *testing.T) { + db := testutils.SetupInMemoryDB(t) + + m, err := NewLeaderboardModel(&tui.LeaderboardInput{ + GameMode: t.Name(), + }, db) + require.NoError(t, err) + tm := teatest.NewTestModel(t, m) + + switchModeMsgCh := make(chan tui.SwitchModeMsg, 1) + go testutils.WaitForMsgOfType(t, tm, switchModeMsgCh, time.Second) + + // exit the leaderboard + tm.Send(tea.KeyMsg{Type: tea.KeyEsc}) + time.Sleep(10 * time.Millisecond) + + // Wait for switch mode message with timeout + select { + case switchModeMsg := <-switchModeMsgCh: + require.Equal(t, tui.ModeMenu, switchModeMsg.Target) + + _, ok := switchModeMsg.Input.(*tui.MenuInput) + require.True(t, ok, "Expected %T, got %T", &tui.MenuInput{}, switchModeMsg.Input) + + case <-time.After(time.Second): + t.Fatal("Timeout waiting for switch mode message") + } +} diff --git a/internal/tui/views/menu.go b/internal/tui/views/menu.go index 14f2b61..6723514 100644 --- a/internal/tui/views/menu.go +++ b/internal/tui/views/menu.go @@ -86,15 +86,6 @@ func (m *MenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } - if m.form.State == huh.StateCompleted { - switch m.hasAnnouncedCompletion { - case false: - return m, m.announceCompletion() - default: - return m, nil - } - } - var cmds []tea.Cmd form, cmd := m.form.Update(msg) if f, ok := form.(*huh.Form); ok { @@ -102,6 +93,10 @@ func (m *MenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) } + if m.form.State == huh.StateCompleted && !m.hasAnnouncedCompletion { + cmds = append(cmds, m.announceCompletion()) + } + return m, tea.Batch(cmds...) } diff --git a/internal/tui/views/menu_test.go b/internal/tui/views/menu_test.go new file mode 100644 index 0000000..3636763 --- /dev/null +++ b/internal/tui/views/menu_test.go @@ -0,0 +1,140 @@ +package views + +import ( + "strings" + "testing" + "time" + + "github.com/Broderick-Westrope/tetrigo/internal/tui" + "github.com/Broderick-Westrope/tetrigo/internal/tui/testutils" + "github.com/Broderick-Westrope/x/exp/teatest" + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMenu_Output(t *testing.T) { + m := NewMenuModel(&tui.MenuInput{}) + tm := teatest.NewTestModel(t, m) + + // Input username + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("testuser")}) + tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) + time.Sleep(10 * time.Millisecond) + + // Select game mode + tm.Send(tea.KeyMsg{Type: tea.KeyDown}) + time.Sleep(10 * time.Millisecond) + tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) + time.Sleep(10 * time.Millisecond) + + // Select level + for range 3 { + tm.Send(tea.KeyMsg{Type: tea.KeyDown}) + time.Sleep(10 * time.Millisecond) + } + + tm.Send(tea.Quit()) + outBytes := []byte(tm.FinalModel(t).View()) + teatest.RequireEqualOutput(t, outBytes) +} + +func TestMenu_SwitchModeMsg(t *testing.T) { + modeToMoveDownCount := func(mode tui.Mode) int { + switch mode { + case tui.ModeMarathon: + return 0 + case tui.ModeSprint: + return 1 + case tui.ModeUltra: + return 2 + case tui.ModeMenu: + fallthrough + case tui.ModeLeaderboard: + fallthrough + default: + return 0 + } + } + + tt := map[string]struct { + username string + mode tui.Mode + level int + }{ + "marathon; level 1": { + username: "testuser", + mode: tui.ModeMarathon, + level: 1, + }, + "sprint; level 3": { + username: "Perry_Crona@hotmail.com", + mode: tui.ModeSprint, + level: 3, + }, + "ultra; level 15": { + username: "testuser", + mode: tui.ModeUltra, + level: 15, + }, + } + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + m := NewMenuModel(&tui.MenuInput{}) + tm := teatest.NewTestModel(t, m) + + switchModeMsgCh := make(chan tui.SwitchModeMsg, 1) + go testutils.WaitForMsgOfType(t, tm, switchModeMsgCh, time.Second) + + // Wait for initial render + var out string + teatest.WaitForOutput(t, tm.Output(), func(bytes []byte) bool { + out = string(bytes) + return strings.Contains(out, "Username:") + }, teatest.WithDuration(time.Second)) + + // Verify expected content is present + assert.Contains(t, out, "Username:") + assert.Contains(t, out, "Game Mode:") + assert.Contains(t, out, "Starting Level:") + + // Input username + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tc.username)}) + tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) + time.Sleep(10 * time.Millisecond) + + // Select game mode + for range modeToMoveDownCount(tc.mode) { + tm.Send(tea.KeyMsg{Type: tea.KeyDown}) + time.Sleep(10 * time.Millisecond) + } + tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) + time.Sleep(10 * time.Millisecond) + + // Select level + for range tc.level - 1 { + tm.Send(tea.KeyMsg{Type: tea.KeyDown}) + time.Sleep(10 * time.Millisecond) + } + tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) + time.Sleep(10 * time.Millisecond) + + // Wait for switch mode message with timeout + select { + case switchModeMsg := <-switchModeMsgCh: + require.Equal(t, tc.mode, switchModeMsg.Target) + + singleInput, ok := switchModeMsg.Input.(*tui.SingleInput) + require.True(t, ok, "Expected %T, got %T", &tui.SingleInput{}, switchModeMsg.Input) + + assert.Equal(t, tc.mode, singleInput.Mode) + assert.Equal(t, tc.level, singleInput.Level) + assert.Equal(t, tc.username, singleInput.Username) + + case <-time.After(time.Second): + t.Fatal("Timeout waiting for switch mode message") + } + }) + } +} diff --git a/internal/tui/views/single.go b/internal/tui/views/single.go index 424fd46..b644c9b 100644 --- a/internal/tui/views/single.go +++ b/internal/tui/views/single.go @@ -2,6 +2,7 @@ package views import ( "fmt" + "math/rand/v2" "strconv" "time" @@ -50,23 +51,27 @@ type SingleModel struct { username string game *single.Game nextQueueLength int - fallStopwatch stopwatch.Model + fallStopwatch components.Stopwatch mode tui.Mode - useTimer bool - gameTimer timer.Model - gameStopwatch stopwatch.Model + gameTimer components.Timer + gameStopwatch components.Stopwatch styles *components.GameStyles help help.Model keys *components.GameKeyMap isPaused bool + rand *rand.Rand width int height int } -func NewSingleModel(in *tui.SingleInput, cfg *config.Config) (*SingleModel, error) { +func NewSingleModel( + in *tui.SingleInput, + cfg *config.Config, + opts ...func(*SingleModel), +) (*SingleModel, error) { // Setup initial model m := &SingleModel{ username: in.Username, @@ -76,6 +81,12 @@ func NewSingleModel(in *tui.SingleInput, cfg *config.Config) (*SingleModel, erro isPaused: false, nextQueueLength: cfg.NextQueueLength, mode: in.Mode, + //nolint:gosec // This random source is not for any security-related tasks. + rand: rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64())), + } + + for _, opt := range opts { + opt(m) } // Get game input @@ -90,7 +101,7 @@ func NewSingleModel(in *tui.SingleInput, cfg *config.Config) (*SingleModel, erro GhostEnabled: cfg.GhostEnabled, } - m.gameStopwatch = stopwatch.NewWithInterval(timerUpdateInterval) + m.gameStopwatch = components.NewStopwatchWithInterval(timerUpdateInterval) case tui.ModeSprint: gameIn = &single.Input{ @@ -103,21 +114,21 @@ func NewSingleModel(in *tui.SingleInput, cfg *config.Config) (*SingleModel, erro GhostEnabled: cfg.GhostEnabled, } - m.gameStopwatch = stopwatch.NewWithInterval(timerUpdateInterval) + m.gameStopwatch = components.NewStopwatchWithInterval(timerUpdateInterval) case tui.ModeUltra: gameIn = &single.Input{ Level: in.Level, GhostEnabled: cfg.GhostEnabled, } - m.useTimer = true - m.gameTimer = timer.NewWithInterval(time.Minute*2, timerUpdateInterval) + m.gameTimer = components.NewTimerWithInterval(time.Minute*2, timerUpdateInterval) case tui.ModeMenu, tui.ModeLeaderboard: fallthrough default: return nil, fmt.Errorf("invalid single player game mode: %v", in.Mode) } + gameIn.Rand = m.rand // Create game var err error @@ -127,17 +138,22 @@ func NewSingleModel(in *tui.SingleInput, cfg *config.Config) (*SingleModel, erro } // Setup game dependents - m.fallStopwatch = stopwatch.NewWithInterval(m.game.GetDefaultFallInterval()) + m.fallStopwatch = components.NewStopwatchWithInterval(m.game.GetDefaultFallInterval()) return m, nil } +func WithRandSource(r *rand.Rand) func(*SingleModel) { + return func(m *SingleModel) { + m.rand = r + } +} + func (m *SingleModel) Init() tea.Cmd { var cmd tea.Cmd - switch m.useTimer { - case true: + if m.gameTimer != nil { cmd = m.gameTimer.Init() - default: + } else { cmd = m.gameStopwatch.Init() } @@ -192,16 +208,25 @@ func (m *SingleModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *SingleModel) dependenciesUpdate(msg tea.Msg) (*SingleModel, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd + var err error - switch m.useTimer { - case true: - m.gameTimer, cmd = m.gameTimer.Update(msg) - default: - m.gameStopwatch, cmd = m.gameStopwatch.Update(msg) + if m.gameTimer != nil { + cmd, err = charmutils.UpdateTypedModel(&m.gameTimer, msg) + if err != nil { + cmds = append(cmds, tui.FatalErrorCmd(err)) + } + } else { + cmd, err = charmutils.UpdateTypedModel(&m.gameStopwatch, msg) + if err != nil { + cmds = append(cmds, tui.FatalErrorCmd(err)) + } } cmds = append(cmds, cmd) - m.fallStopwatch, cmd = m.fallStopwatch.Update(msg) + cmd, err = charmutils.UpdateTypedModel(&m.fallStopwatch, msg) + if err != nil { + cmds = append(cmds, tui.FatalErrorCmd(err)) + } cmds = append(cmds, cmd) return m, tea.Batch(cmds...) @@ -249,7 +274,7 @@ func (m *SingleModel) playingUpdate(msg tea.Msg) (*SingleModel, tea.Cmd) { if msg.ID != m.fallStopwatch.ID() { break } - m.fallStopwatch.Interval = m.game.GetDefaultFallInterval() + m.fallStopwatch.SetInterval(m.game.GetDefaultFallInterval()) gameOver, err := m.game.TickLower() if err != nil { return nil, tui.FatalErrorCmd(fmt.Errorf("lowering tetrimino (tick): %w", err)) @@ -301,7 +326,7 @@ func (m *SingleModel) playingKeyMsgUpdate(msg tea.KeyMsg) (*SingleModel, tea.Cmd cmds = append(cmds, m.fallStopwatch.Reset()) return m, tea.Batch(cmds...) case key.Matches(msg, m.keys.SoftDrop): - m.fallStopwatch.Interval = m.game.ToggleSoftDrop() + m.fallStopwatch.SetInterval(m.game.ToggleSoftDrop()) // TODO: find a fix for "pausing" momentarily before soft drop begins // cmds = append(cmds, func() tea.Msg { // return stopwatch.TickMsg{ID: m.fallStopwatch.ID()} @@ -396,10 +421,9 @@ func (m *SingleModel) informationView() string { } var gameTime float64 - switch m.useTimer { - case true: - gameTime = m.gameTimer.Timeout.Seconds() - default: + if m.gameTimer != nil { + gameTime = m.gameTimer.GetTimeout().Seconds() + } else { gameTime = m.gameStopwatch.Elapsed().Seconds() } @@ -478,20 +502,29 @@ func (m *SingleModel) triggerGameOver() tea.Cmd { m.game.EndGame() m.isPaused = false - if m.useTimer { - m.gameTimer.Timeout = 0 + var cmds []tea.Cmd + if m.gameTimer != nil { + m.gameTimer.SetTimeout(0) + cmds = append(cmds, m.gameTimer.Stop()) + } else { + cmds = append(cmds, m.fallStopwatch.Stop()) } - var cmds []tea.Cmd - cmds = append(cmds, m.gameTimer.Stop()) - cmds = append(cmds, m.fallStopwatch.Stop()) return tea.Batch(cmds...) } func (m *SingleModel) togglePause() tea.Cmd { m.isPaused = !m.isPaused + + var cmd tea.Cmd + if m.gameTimer != nil { + cmd = m.gameTimer.Toggle() + } else { + cmd = m.gameStopwatch.Toggle() + } + return tea.Batch( m.fallStopwatch.Toggle(), - m.gameTimer.Toggle(), + cmd, ) } diff --git a/internal/tui/views/single_test.go b/internal/tui/views/single_test.go new file mode 100644 index 0000000..c05d6ae --- /dev/null +++ b/internal/tui/views/single_test.go @@ -0,0 +1,252 @@ +package views + +import ( + "math/rand/v2" + "testing" + "time" + + "github.com/Broderick-Westrope/tetrigo/internal/config" + "github.com/Broderick-Westrope/tetrigo/internal/data" + "github.com/Broderick-Westrope/tetrigo/internal/tui" + "github.com/Broderick-Westrope/tetrigo/internal/tui/components" + "github.com/Broderick-Westrope/tetrigo/internal/tui/testutils" + "github.com/Broderick-Westrope/x/exp/teatest" + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestSingle_InitialOutput(t *testing.T) { + r := rand.New(rand.NewPCG(0, 0)) + + m, err := NewSingleModel( + &tui.SingleInput{ + Mode: tui.ModeMarathon, + Level: 1, + Username: "testuser", + }, + &config.Config{ + NextQueueLength: 0, + GhostEnabled: true, + LockDownMode: "", + MaxLevel: 0, + EndOnMaxLevel: false, + Theme: config.DefaultTheme(), + Keys: config.DefaultKeys(), + }, + WithRandSource(r), + ) + require.NoError(t, err) + tm := teatest.NewTestModel(t, m) + + tm.Send(tea.Quit()) + outBytes := []byte(tm.FinalModel(t).View()) + teatest.RequireEqualOutput(t, outBytes) +} + +func TestSingle_Interaction(t *testing.T) { + mockGameStopwatch := components.NewMockStopwatch(t) + mockGameStopwatch.EXPECT().Init().Return(nil) + mockGameStopwatch.EXPECT().Update(mock.Anything).Return(mockGameStopwatch, nil) + mockGameStopwatch.EXPECT().Elapsed().Return(time.Duration(0)) + + m, err := NewSingleModel( + &tui.SingleInput{ + Mode: tui.ModeMarathon, + Level: 1, + Username: "testuser", + }, + &config.Config{ + NextQueueLength: 0, + GhostEnabled: true, + LockDownMode: "", + MaxLevel: 0, + EndOnMaxLevel: false, + Theme: config.DefaultTheme(), + Keys: config.DefaultKeys(), + }, + WithRandSource(rand.New(rand.NewPCG(0, 0))), + ) + require.NoError(t, err) + m.gameStopwatch = mockGameStopwatch + tm := teatest.NewTestModel(t, m) + + // hold + tm.Send(tea.KeyMsg{Type: tea.KeySpace}) + time.Sleep(10 * time.Millisecond) + + // left 3 + for range 3 { + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}) + time.Sleep(10 * time.Millisecond) + } + + // hard drop + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("w")}) + time.Sleep(10 * time.Millisecond) + + // right + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}) + time.Sleep(10 * time.Millisecond) + + // hard drop + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("w")}) + time.Sleep(10 * time.Millisecond) + + // hold + tm.Send(tea.KeyMsg{Type: tea.KeySpace}) + time.Sleep(10 * time.Millisecond) + + // right 4 + for range 4 { + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}) + time.Sleep(10 * time.Millisecond) + } + + // hard drop + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("w")}) + time.Sleep(10 * time.Millisecond) + + tm.Send(tea.Quit()) + outBytes := []byte(tm.FinalModel(t, teatest.WithFinalTimeout(time.Second)).View()) + teatest.RequireEqualOutput(t, outBytes) +} + +// TODO: The golden file of this test shows that the ghost is +// written over the top of the placed Tet at the skyline. +// TODO: Fix formatting for the output. The golden file shows +// that escape sequences of the background are not handled +// correctly when overlaying the modal. +func TestSingle_GameOverOutput(t *testing.T) { + mockGameStopwatch := components.NewMockStopwatch(t) + mockGameStopwatch.EXPECT().Init().Return(nil) + mockGameStopwatch.EXPECT().Update(mock.Anything).Return(mockGameStopwatch, nil) + mockGameStopwatch.EXPECT().Elapsed().Return(time.Duration(0)) + + m, err := NewSingleModel( + &tui.SingleInput{ + Mode: tui.ModeMarathon, + Level: 1, + Username: "testuser", + }, + &config.Config{ + NextQueueLength: 0, + GhostEnabled: true, + LockDownMode: "", + MaxLevel: 0, + EndOnMaxLevel: false, + Theme: config.DefaultTheme(), + Keys: config.DefaultKeys(), + }, + WithRandSource(rand.New(rand.NewPCG(0, 0))), + ) + require.NoError(t, err) + m.gameStopwatch = mockGameStopwatch + tm := teatest.NewTestModel(t, m) + + // hard drop 12 + for range 12 { + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("w")}) + time.Sleep(10 * time.Millisecond) + } + + tm.Send(tea.Quit()) + outBytes := []byte(tm.FinalModel(t, teatest.WithFinalTimeout(time.Second)).View()) + teatest.RequireEqualOutput(t, outBytes) +} + +func TestSingle_PausedOutput(t *testing.T) { + mockGameStopwatch := components.NewMockStopwatch(t) + mockGameStopwatch.EXPECT().Init().Return(nil) + mockGameStopwatch.EXPECT().Update(mock.Anything).Return(mockGameStopwatch, nil) + mockGameStopwatch.EXPECT().Elapsed().Return(time.Duration(0)) + mockGameStopwatch.EXPECT().Toggle().Return(nil) + + m, err := NewSingleModel( + &tui.SingleInput{ + Mode: tui.ModeMarathon, + Level: 1, + Username: "testuser", + }, + &config.Config{ + NextQueueLength: 0, + GhostEnabled: true, + LockDownMode: "", + MaxLevel: 0, + EndOnMaxLevel: false, + Theme: config.DefaultTheme(), + Keys: config.DefaultKeys(), + }, + WithRandSource(rand.New(rand.NewPCG(0, 0))), + ) + require.NoError(t, err) + m.gameStopwatch = mockGameStopwatch + tm := teatest.NewTestModel(t, m) + + tm.Send(tea.KeyMsg{Type: tea.KeyEsc}) + time.Sleep(10 * time.Millisecond) + + tm.Send(tea.Quit()) + outBytes := []byte(tm.FinalModel(t, teatest.WithFinalTimeout(time.Second)).View()) + teatest.RequireEqualOutput(t, outBytes) +} + +func TestSingle_GameOverSwitchModeMsg(t *testing.T) { + m, err := NewSingleModel( + &tui.SingleInput{ + Mode: tui.ModeMarathon, + Level: 1, + Username: "testuser", + }, + &config.Config{ + NextQueueLength: 0, + GhostEnabled: true, + LockDownMode: "", + MaxLevel: 0, + EndOnMaxLevel: false, + Theme: config.DefaultTheme(), + Keys: config.DefaultKeys(), + }, + WithRandSource(rand.New(rand.NewPCG(0, 0))), + ) + require.NoError(t, err) + tm := teatest.NewTestModel(t, m) + + switchModeMsgCh := make(chan tui.SwitchModeMsg, 1) + go testutils.WaitForMsgOfType(t, tm, switchModeMsgCh, time.Second) + + // hard drop 12 + for range 12 { + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("w")}) + time.Sleep(10 * time.Millisecond) + } + + // continue past game over message + tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) + time.Sleep(10 * time.Millisecond) + + // Wait for switch mode message with timeout + select { + case switchModeMsg := <-switchModeMsgCh: + require.Equal(t, tui.ModeLeaderboard, switchModeMsg.Target) + + leaderboardInput, ok := switchModeMsg.Input.(*tui.LeaderboardInput) + require.True(t, ok, "Expected %T, got %T", &tui.LeaderboardInput{}, switchModeMsg.Input) + + assert.Equal(t, tui.ModeMarathon.String(), leaderboardInput.GameMode) + assert.EqualValues(t, &data.Score{ + ID: 0, + Rank: 0, + GameMode: tui.ModeMarathon.String(), + Name: "testuser", + Time: time.Duration(0), + Score: 230, + Lines: 0, + Level: 1, + }, leaderboardInput.NewEntry) + + case <-time.After(time.Second): + t.Fatal("Timeout waiting for switch mode message") + } +} diff --git a/internal/tui/views/testdata/TestLeaderboard_KeyboardNavigation.golden b/internal/tui/views/testdata/TestLeaderboard_KeyboardNavigation.golden new file mode 100644 index 0000000..671bdbb --- /dev/null +++ b/internal/tui/views/testdata/TestLeaderboard_KeyboardNavigation.golden @@ -0,0 +1,23 @@ + Rank Name Time Score Lines Level +──────────────────────────────────────────────────────── + 14 user-36 1m12s 3600 36 38 + 15 user-35 1m10s 3500 35 37 + 16 user-34 1m8s 3400 34 36 + 17 user-33 1m6s 3300 33 35 + 18 user-32 1m4s 3200 32 34 + 19 user-31 1m2s 3100 31 33 + 20 user-30 1m0s 3000 30 32 + 21 user-29 58s 2900 29 31 + 22 user-28 56s 2800 28 30 + 23 user-27 54s 2700 27 29 + 24 user-26 52s 2600 26 28 + 25 user-25 50s 2500 25 27 + 26 user-24 48s 2400 24 26 + 27 user-23 46s 2300 23 25 + 28 user-22 44s 2200 22 24 + 29 user-21 42s 2100 21 23 + 30 user-new 1m0s 2001 2 3 + 31 user-20 40s 2000 20 22 + 32 user-19 38s 1900 19 21 + 33 user-18 36s 1800 18 20 +escape exit • ? help \ No newline at end of file diff --git a/internal/tui/views/testdata/TestLeaderboard_NewEntryInEmptyTable.golden b/internal/tui/views/testdata/TestLeaderboard_NewEntryInEmptyTable.golden new file mode 100644 index 0000000..db87ac7 --- /dev/null +++ b/internal/tui/views/testdata/TestLeaderboard_NewEntryInEmptyTable.golden @@ -0,0 +1,23 @@ + Rank Name Time Score Lines Level +──────────────────────────────────────────────────────── + 1 user-new 1m0s 1000 2 3 + + + + + + + + + + + + + + + + + + + +escape exit • ? help \ No newline at end of file diff --git a/internal/tui/views/testdata/TestLeaderboard_TableEntries/0_(empty).golden b/internal/tui/views/testdata/TestLeaderboard_TableEntries/0_(empty).golden new file mode 100644 index 0000000..feb1f08 --- /dev/null +++ b/internal/tui/views/testdata/TestLeaderboard_TableEntries/0_(empty).golden @@ -0,0 +1,23 @@ + Rank Name Time Score Lines Level +──────────────────────────────────────────────────────── + + + + + + + + + + + + + + + + + + + + +escape exit • ? help \ No newline at end of file diff --git a/internal/tui/views/testdata/TestLeaderboard_TableEntries/3_(partial).golden b/internal/tui/views/testdata/TestLeaderboard_TableEntries/3_(partial).golden new file mode 100644 index 0000000..7057052 --- /dev/null +++ b/internal/tui/views/testdata/TestLeaderboard_TableEntries/3_(partial).golden @@ -0,0 +1,23 @@ + Rank Name Time Score Lines Level +──────────────────────────────────────────────────────── + 1 user-2 4s 200 2 4 + 2 user-1 2s 100 1 3 + 3 user-0 0s 0 0 2 + + + + + + + + + + + + + + + + + +escape exit • ? help \ No newline at end of file diff --git a/internal/tui/views/testdata/TestLeaderboard_TableEntries/50_(overfull).golden b/internal/tui/views/testdata/TestLeaderboard_TableEntries/50_(overfull).golden new file mode 100644 index 0000000..85987c8 --- /dev/null +++ b/internal/tui/views/testdata/TestLeaderboard_TableEntries/50_(overfull).golden @@ -0,0 +1,23 @@ + Rank Name Time Score Lines Level +──────────────────────────────────────────────────────── + 1 user-49 1m38s 4900 49 51 + 2 user-48 1m36s 4800 48 50 + 3 user-47 1m34s 4700 47 49 + 4 user-46 1m32s 4600 46 48 + 5 user-45 1m30s 4500 45 47 + 6 user-44 1m28s 4400 44 46 + 7 user-43 1m26s 4300 43 45 + 8 user-42 1m24s 4200 42 44 + 9 user-41 1m22s 4100 41 43 + 10 user-40 1m20s 4000 40 42 + 11 user-39 1m18s 3900 39 41 + 12 user-38 1m16s 3800 38 40 + 13 user-37 1m14s 3700 37 39 + 14 user-36 1m12s 3600 36 38 + 15 user-35 1m10s 3500 35 37 + 16 user-34 1m8s 3400 34 36 + 17 user-33 1m6s 3300 33 35 + 18 user-32 1m4s 3200 32 34 + 19 user-31 1m2s 3100 31 33 + 20 user-30 1m0s 3000 30 32 +escape exit • ? help \ No newline at end of file diff --git a/internal/tui/views/testdata/TestMenu_Output.golden b/internal/tui/views/testdata/TestMenu_Output.golden new file mode 100644 index 0000000..24a6169 --- /dev/null +++ b/internal/tui/views/testdata/TestMenu_Output.golden @@ -0,0 +1,34 @@ + + ______________________ ______________ + /_ __/ ____/_ __/ __ \/ _/ ____/ __ \ + / / / __/ / / / /_/ // // / __/ / / / + / / / /___ / / / _, _// // /_/ / /_/ / + /_/ /_____/ /_/ /_/ |_/___/\____/\____/ + + + Username: + > testuser + + Game Mode: + Marathon + > Sprint (40 Lines) + Ultra (Time Trial) + +┃ Starting Level: +┃ 1 +┃ 2 +┃ 3 +┃ > 4 +┃ 5 +┃ 6 +┃ 7 +┃ 8 +┃ 9 +┃ 10 +┃ 11 +┃ 12 +┃ 13 +┃ 14 +┃ 15 + + ↑ up • ↓ down • / filter • shift+tab back • enter submit \ No newline at end of file diff --git a/internal/tui/views/testdata/TestSingle_GameOverOutput.golden b/internal/tui/views/testdata/TestSingle_GameOverOutput.golden new file mode 100644 index 0000000..76dadc0 --- /dev/null +++ b/internal/tui/views/testdata/TestSingle_GameOverOutput.golden @@ -0,0 +1,23 @@ + ╭──────────╭────────────────────╮ + │ Hold: │▕ ▕ ▕ ▕ ░░░░▕ ▕ ▕ ▕ │ 1 Next: + │ │▕ ▕ ▕ ██░░░░▕ ▕ ▕ ▕ │ 2 + │ │▕ ▕ ▕ ██████▕ ▕ ▕ ▕ │ 3 + │ │▕ ▕ ▕ ▕ ████▕ ▕ ▕ ▕ │ 4 + │ │▕ ▕ ▕ ████▕ ▕ ▕ ▕ ▕ │ 5 + + G______ ____ +Sc/ ____/___ _____ ___ ___ / __ \_ _____ _____ + / / __/ __ ^/ __ ^__ \/ _ \ / / / / | / / _ \/ ___/ +/ /_/ / /_/ / / / / / / __/ / /_/ /| |/ / __/ / +\____/\__,_/_/ /_/ /_/\___/ \____/ |___/\___/_/ + + Press EXIT or HOLD to continue. ▕ │ 13 + + │▕ ▕ ▕ ▕ ████▕ ▕ ▕ ▕ │ 15 + │▕ ▕ ▕ ▕ ██▕ ▕ ▕ ▕ ▕ │ 16 + │▕ ▕ ▕ ██████▕ ▕ ▕ ▕ │ 17 + │▕ ▕ ▕ ████████▕ ▕ ▕ │ 18 + │▕ ▕ ▕ ▕ ▕ ██▕ ▕ ▕ ▕ │ 19 + │▕ ▕ ▕ ██████▕ ▕ ▕ ▕ │ 20 + ╰────────────────────╯ +esc exit • ? help \ No newline at end of file diff --git a/internal/tui/views/testdata/TestSingle_InitialOutput.golden b/internal/tui/views/testdata/TestSingle_InitialOutput.golden new file mode 100644 index 0000000..97d72cb --- /dev/null +++ b/internal/tui/views/testdata/TestSingle_InitialOutput.golden @@ -0,0 +1,23 @@ + ╭──────────╭────────────────────╮ + │ Hold: │▕ ▕ ▕ ██████▕ ▕ ▕ ▕ │ 1 Next: + │ │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 2 + │ │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 3 + │ │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 4 + │ │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 5 + ╰──────────│▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 6 + MARATHON │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 7 +Score: │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 8 + 0 │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 9 +Time: │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 10 + 00.000 │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 11 +Lines: 0 │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 12 +Level: 1 │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 13 + │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 14 + │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 15 + │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 16 + │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 17 + │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 18 + │▕ ▕ ▕ ▕ ▕ ░░▕ ▕ ▕ ▕ │ 19 + │▕ ▕ ▕ ░░░░░░▕ ▕ ▕ ▕ │ 20 + ╰────────────────────╯ +esc exit • ? help \ No newline at end of file diff --git a/internal/tui/views/testdata/TestSingle_Interaction.golden b/internal/tui/views/testdata/TestSingle_Interaction.golden new file mode 100644 index 0000000..3598f91 --- /dev/null +++ b/internal/tui/views/testdata/TestSingle_Interaction.golden @@ -0,0 +1,23 @@ + ╭──────────╭────────────────────╮ + │ Hold: │▕ ▕ ▕ ▕ ████▕ ▕ ▕ ▕ │ 1 Next: + │ │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 2 + │ ████ │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 3 + │ ████ │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 4 + │ │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 5 + ╰──────────│▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 6 + MARATHON │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 7 +Score: │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 8 + 214 │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 9 +Time: │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 10 + 00.000 │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 11 +Lines: 1 │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 12 +Level: 1 │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 13 + │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 14 + │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 15 + │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 16 + │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 17 + │▕ ▕ ▕ ░░░░▕ ▕ ▕ ▕ ▕ │ 18 + │▕ ▕ ▕ ▕ ░░░░▕ ▕ ▕ ▕ │ 19 + │▕ ▕ ▕ ▕ ▕ ██▕ ▕ ▕ ██│ 20 + ╰────────────────────╯ +esc exit • ? help \ No newline at end of file diff --git a/internal/tui/views/testdata/TestSingle_PausedOutput.golden b/internal/tui/views/testdata/TestSingle_PausedOutput.golden new file mode 100644 index 0000000..544edf4 --- /dev/null +++ b/internal/tui/views/testdata/TestSingle_PausedOutput.golden @@ -0,0 +1,23 @@ + ╭──────────╭────────────────────╮ + │ Hold: │▕ ▕ ▕ ██████▕ ▕ ▕ ▕ │ 1 Next: + │ │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 2 + │ │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 3 + │ │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 4 + │ │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 5 + ╰──────────│▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 6 + PAUSED │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 7 +Sc ____ __8 + / __ \____ ___ __________ ____/ / +Time/ /_/ / __ ^/ / / / ___/ _ \/ __ / + / ____/ /_/ / /_/ (__ ) __/ /_/ / +Li/_/ \__,_/\__,_/____/\___/\__,_/2 +LePress PAUSE to continue or HOLD to exit. + │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 14 + │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 15 + │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 16 + │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 17 + │▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ ▕ │ 18 + │▕ ▕ ▕ ▕ ▕ ░░▕ ▕ ▕ ▕ │ 19 + │▕ ▕ ▕ ░░░░░░▕ ▕ ▕ ▕ │ 20 + ╰────────────────────╯ +esc exit • ? help \ No newline at end of file diff --git a/pkg/tetris/fall_test.go b/pkg/tetris/fall_test.go new file mode 100644 index 0000000..0db8b4d --- /dev/null +++ b/pkg/tetris/fall_test.go @@ -0,0 +1,103 @@ +package tetris + +import ( + "math" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// calculateExpectedSpeed helper function to match the formula exactly. +func calculateExpectedSpeed(level int) time.Duration { + decrementedLevel := float64(level - 1) + speed := math.Pow(0.8-(decrementedLevel*0.007), decrementedLevel) + return time.Duration(speed * float64(time.Second)) +} + +func TestNewFall(t *testing.T) { + // Test initialization with different levels. + tt := map[string]struct { + level int + }{ + "level 1": {level: 1}, + "level 15": {level: 15}, + "level 30": {level: 30}, + } + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + f := NewFall(tc.level) + + expectedDefault := calculateExpectedSpeed(tc.level) + expectedSoftDrop := time.Duration(float64(expectedDefault) / 15) + + assert.Equal(t, expectedDefault, f.DefaultInterval, + "default fall speed should be %v, got %v", expectedDefault, f.DefaultInterval) + + assert.Equal(t, expectedSoftDrop, f.SoftDropInterval, + "soft drop speed should be %v, got %v", expectedSoftDrop, f.SoftDropInterval) + + assert.False(t, f.IsSoftDrop, "IsSoftDrop should be false by default") + }) + } +} + +func TestFall_CalculateFallSpeeds(t *testing.T) { + f := &Fall{} + + // Test that recalculating speeds works. + t.Run("recalculate speeds", func(t *testing.T) { + // Start with level 1 + f.CalculateFallSpeeds(1) + initialDefault := f.DefaultInterval + initialSoftDrop := f.SoftDropInterval + + // Recalculate for level 10. + f.CalculateFallSpeeds(10) + + assert.Greater(t, initialDefault, f.DefaultInterval, + "level 10 should be faster (smaller interval) than level 1") + assert.Greater(t, initialSoftDrop, f.SoftDropInterval, + "soft drop at level 10 should be faster than level 1") + }) + + // Test that soft drop is always faster. + t.Run("soft drop faster than default", func(t *testing.T) { + levels := []int{1, 5, 10, 15, 20} + for _, level := range levels { + f.CalculateFallSpeeds(level) + assert.Greater(t, f.DefaultInterval, f.SoftDropInterval, + "soft drop should be faster (smaller interval) than default at level %d", level) + } + }) +} + +func TestFall_ToggleSoftDrop(t *testing.T) { + f := NewFall(1) + + assert.False(t, f.IsSoftDrop, "should start with soft drop disabled") + + f.ToggleSoftDrop() + assert.True(t, f.IsSoftDrop, "should enable soft drop after first toggle") + + f.ToggleSoftDrop() + assert.False(t, f.IsSoftDrop, "should disable soft drop after second toggle") +} + +func TestFall_SpeedProgression(t *testing.T) { + f := &Fall{} + var prevInterval time.Duration + + // Test first 20 levels + for level := 1; level <= 20; level++ { + f.CalculateFallSpeeds(level) + + if level > 1 { + assert.Greater(t, prevInterval, f.DefaultInterval, + "fall speed should increase (interval should decrease) from level %d to %d", level-1, level) + } + + prevInterval = f.DefaultInterval + } +} diff --git a/pkg/tetris/modes/single/single.go b/pkg/tetris/modes/single/single.go index 707fc95..1f1b3b4 100644 --- a/pkg/tetris/modes/single/single.go +++ b/pkg/tetris/modes/single/single.go @@ -3,6 +3,7 @@ package single import ( "errors" "fmt" + "math/rand/v2" "time" "github.com/Broderick-Westrope/tetrigo/pkg/tetris" @@ -32,7 +33,8 @@ type Input struct { MaxLines int // The maximum number of lines to clear before the game ends. 0 means no limit. EndOnMaxLines bool // Whether the game should end when the maximum number of lines is cleared. - GhostEnabled bool // Whether the ghost Tetrimino should be displayed. + GhostEnabled bool // Whether the ghost Tetrimino should be displayed. + Rand *rand.Rand // The random source to use for Tetrimino generation. } func NewGame(in *Input) (*Game, error) { @@ -40,7 +42,7 @@ func NewGame(in *Input) (*Game, error) { if err != nil { return nil, err } - nq := tetris.NewNextQueue(matrix.GetSkyline()) + nq := tetris.NewNextQueue(matrix.GetSkyline(), tetris.WithRandSource(in.Rand)) scoring, err := tetris.NewScoring( in.Level, in.MaxLevel, in.IncreaseLevel, in.EndOnMaxLevel, in.MaxLines, in.EndOnMaxLines, diff --git a/pkg/tetris/next_queue.go b/pkg/tetris/next_queue.go index 775e852..5125454 100644 --- a/pkg/tetris/next_queue.go +++ b/pkg/tetris/next_queue.go @@ -1,7 +1,7 @@ package tetris import ( - "math/rand" + "math/rand/v2" ) // NextQueue is a collection of up to 14 Tetriminos that are drawn from randomly. @@ -9,16 +9,30 @@ import ( type NextQueue struct { elements []Tetrimino skyline int + rand *rand.Rand } // NewNextQueue creates a new NextQueue of Tetriminos. -func NewNextQueue(skyline int) *NextQueue { - nq := NextQueue{ +func NewNextQueue(skyline int, opts ...func(*NextQueue)) *NextQueue { + nq := &NextQueue{ elements: make([]Tetrimino, 0, 14), skyline: skyline, + //nolint:gosec // This random source is not for any security-related tasks. + rand: rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64())), } + + for _, opt := range opts { + opt(nq) + } + nq.fill() - return &nq + return nq +} + +func WithRandSource(rand *rand.Rand) func(*NextQueue) { + return func(nq *NextQueue) { + nq.rand = rand + } } // GetElements returns the Tetriminos in the queue. @@ -48,7 +62,7 @@ func (nq *NextQueue) fill() { } tetriminos := GetValidTetriminos() - perm := rand.Perm(len(tetriminos)) + perm := nq.rand.Perm(len(tetriminos)) for _, i := range perm { if len(nq.elements) == 14 { // This should be impossible whilst there are only 7 Tetriminos and we check that there is space for 7 in the queue diff --git a/pkg/tetris/next_queue_test.go b/pkg/tetris/next_queue_test.go index 38ded11..c88fa9b 100644 --- a/pkg/tetris/next_queue_test.go +++ b/pkg/tetris/next_queue_test.go @@ -59,10 +59,8 @@ func TestNextQueue_Next(t *testing.T) { for name, tc := range tt { t.Run(name, func(t *testing.T) { - nq := NextQueue{ - elements: tc.elements, - skyline: 40, - } + nq := NewNextQueue(40) + nq.elements = tc.elements expected := tc.elements[0].DeepCopy() expected.Position.Y += nq.skyline @@ -127,10 +125,8 @@ func TestNextQueue_Fill(t *testing.T) { for name, tc := range tt { t.Run(name, func(t *testing.T) { - nq := NextQueue{ - elements: tc.elements, - skyline: 40, - } + nq := NewNextQueue(40) + nq.elements = tc.elements for range tc.timesToFill { nq.fill() diff --git a/pkg/tetris/tetrimino.go b/pkg/tetris/tetrimino.go index 9cd7583..c5744eb 100644 --- a/pkg/tetris/tetrimino.go +++ b/pkg/tetris/tetrimino.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "math" + "slices" ) const invalidRotationPoint = -1 @@ -182,12 +183,23 @@ func getMapOfValidTetriminos() map[byte]Tetrimino { } // GetValidTetriminos returns a slice containing all seven valid Tetriminos (I, O, T, S, Z, J, L). +// The Tetrminos are sorted by their value to ensure determinism in tests. func GetValidTetriminos() []Tetrimino { validTetriminos := getMapOfValidTetriminos() result := make([]Tetrimino, 0, len(validTetriminos)) for _, t := range validTetriminos { result = append(result, t) } + slices.SortFunc(result, func(a, b Tetrimino) int { + switch { + case a.Value < b.Value: + return -1 + case a.Value > b.Value: + return 1 + default: + return 0 + } + }) return result }