From de624ec50c48fe6f8ebdf54f8272d4bdae29f928 Mon Sep 17 00:00:00 2001 From: sha1n Date: Fri, 16 Jan 2026 22:54:19 +0200 Subject: [PATCH 01/12] fix: remove deprecated rand.Seed call Go 1.20+ automatically seeds the global random source, making explicit rand.Seed() calls unnecessary and deprecated. Removed the redundant seeding in demoMatrix(). --- internal/main.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/main.go b/internal/main.go index 9d7a8fa..9e8b969 100644 --- a/internal/main.go +++ b/internal/main.go @@ -96,7 +96,6 @@ func demoMatrix(ctx *demoContext) { row.Update(fmt.Sprintf("- Matrix Task %d - %s", rowIndex+1, status)) } - rand.Seed(time.Now().UnixNano()) indexes := []int{0, 1, 2, 3, 4} // update the matrix for _, status := range progressPhases { From 1c90a8b931008f8960ae583a463cf1173f4570a3 Mon Sep 17 00:00:00 2001 From: sha1n Date: Fri, 16 Jan 2026 22:55:37 +0200 Subject: [PATCH 02/12] fix: replace deprecated strings.Title with golang.org/x/text/cases strings.Title is deprecated due to not handling Unicode properly. Use cases.Title from golang.org/x/text for correct title casing. Added golang.org/x/text v0.33.0 dependency. --- go.mod | 1 + go.sum | 2 ++ internal/main.go | 4 +++- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index e0ddf87..4fb7343 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/mattn/go-isatty v0.0.20 github.com/sha1n/gommons v0.0.19 github.com/stretchr/testify v1.11.1 + golang.org/x/text v0.33.0 ) require ( diff --git a/go.sum b/go.sum index 5a6245b..8a4c8f9 100644 --- a/go.sum +++ b/go.sum @@ -19,6 +19,8 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= 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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/main.go b/internal/main.go index 9d7a8fa..2b21d2d 100644 --- a/internal/main.go +++ b/internal/main.go @@ -11,6 +11,8 @@ import ( "github.com/fatih/color" "github.com/sha1n/termite" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) var taskDoneMarkUniChar = color.GreenString("\u2714") @@ -241,7 +243,7 @@ func printTitle(s string, ctx *demoContext) { chars := len(s) border := strings.Repeat("-", chars+2) termite.Println(border) - termite.Println(fmt.Sprintf(" %s ", color.GreenString(strings.Title(s)))) + termite.Println(fmt.Sprintf(" %s ", color.GreenString(cases.Title(language.English).String(s)))) termite.Println(border) termite.Println("") } From 4ac261178cc0ec75ba2985fec0319a9e1d65cf9b Mon Sep 17 00:00:00 2001 From: sha1n Date: Fri, 16 Jan 2026 22:56:27 +0200 Subject: [PATCH 03/12] refactor: remove custom min/max functions Go 1.21+ has built-in min() and max() generic functions. Remove the redundant custom implementations. --- progress_bar.go | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/progress_bar.go b/progress_bar.go index 2dce351..83f60d7 100644 --- a/progress_bar.go +++ b/progress_bar.go @@ -255,17 +255,3 @@ func (b *bar) render(message string) bool { return b.maxTicks > b.ticks } - -func min(a, b int) int { - if a < b { - return a - } - return b -} - -func max(a, b int) int { - if a > b { - return a - } - return b -} From cbd1c8dfec73b4c4ca56eb4ed3705c2accf5c618 Mon Sep 17 00:00:00 2001 From: sha1n Date: Fri, 16 Jan 2026 22:57:44 +0200 Subject: [PATCH 04/12] refactor: remove unused isActiveSafe method The isActiveSafe() method was only used in tests and exposed internal implementation details. Refactored test to use behavioral verification (checking no output is produced) instead of checking internal state. --- spinner.go | 7 ------- spinner_test.go | 15 ++++----------- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/spinner.go b/spinner.go index 395af81..a070a8b 100644 --- a/spinner.go +++ b/spinner.go @@ -206,13 +206,6 @@ func (s *spinner) createSpinnerRing() *ring.Ring { return r } -func (s *spinner) isActiveSafe() bool { - s.stateMx.RLock() - defer s.stateMx.RUnlock() - - return s.active -} - func (s *spinner) setActiveSafe(active bool) { s.stateMx.Lock() defer s.stateMx.Unlock() diff --git a/spinner_test.go b/spinner_test.go index 2bafa7e..18adb8b 100644 --- a/spinner_test.go +++ b/spinner_test.go @@ -149,25 +149,18 @@ func bufferContains(outBuffer *bytes.Buffer, expected string) func() bool { } } -func assertStoppedEventually(t *testing.T, probedWriter *io.ProbedWriter, spinner *spinner) { - assert.Eventually( - t, - func() bool { return !spinner.isActiveSafe() }, - timeout, - interval, - "expected spinner to deactivate", - ) - +func assertStoppedEventually(t *testing.T, probedWriter *io.ProbedWriter, spin *spinner) { + // Verify the spinner has stopped by checking no more output is produced assert.Eventually( t, func() bool { probedWriter.Reset() - time.Sleep(spinner.interval * 2) + time.Sleep(spin.interval * 2) return len(probedWriter.Bytes()) == 0 }, timeout, - spinner.interval, + spin.interval, "expected no more output from spinner", ) } From 356dec88be24ba029d6c2ea708ec480ca802ccb8 Mon Sep 17 00:00:00 2001 From: sha1n Date: Fri, 16 Jan 2026 22:58:57 +0200 Subject: [PATCH 05/12] refactor: make cursor.writeString error return explicit Changed writeString to return (int, error) to properly handle the io.WriteString return values. Callers explicitly ignore errors using blank identifiers, making the intentional error handling clear. This improves code transparency without changing behavior. --- cursor.go | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/cursor.go b/cursor.go index 5fb8263..67354a4 100644 --- a/cursor.go +++ b/cursor.go @@ -30,41 +30,41 @@ func NewCursor(writer io.Writer) Cursor { } func (c cursor) Position(row, col int) { - c.writeString(fmt.Sprintf(termControlCursorPositionFmt, row, col)) + _, _ = c.writeString(fmt.Sprintf(termControlCursorPositionFmt, row, col)) } func (c cursor) Up(lines int) { - c.writeString(fmt.Sprintf(termControlCursorUpFmt, lines)) + _, _ = c.writeString(fmt.Sprintf(termControlCursorUpFmt, lines)) } func (c cursor) Down(lines int) { - c.writeString(fmt.Sprintf(termControlCursorDownFmt, lines)) + _, _ = c.writeString(fmt.Sprintf(termControlCursorDownFmt, lines)) } func (c cursor) Forward(cols int) { - c.writeString(fmt.Sprintf(termControlCursorForwardFmt, cols)) + _, _ = c.writeString(fmt.Sprintf(termControlCursorForwardFmt, cols)) } func (c cursor) Backward(cols int) { - c.writeString(fmt.Sprintf(termControlCursorBackwardFmt, cols)) + _, _ = c.writeString(fmt.Sprintf(termControlCursorBackwardFmt, cols)) } func (c cursor) Hide() { - c.writeString(termControlCursorHide) + _, _ = c.writeString(termControlCursorHide) } func (c cursor) Show() { - c.writeString(termControlCursorShow) + _, _ = c.writeString(termControlCursorShow) } func (c cursor) SavePosition() { - c.writeString(termControlCursorSave) + _, _ = c.writeString(termControlCursorSave) } func (c cursor) RestorePosition() { - c.writeString(termControlCursorRestore) + _, _ = c.writeString(termControlCursorRestore) } -func (c cursor) writeString(s string) { - io.WriteString(c.writer, s) +func (c cursor) writeString(s string) (int, error) { + return io.WriteString(c.writer, s) } From 5bb9f7e91dce49c7b32ce79969de26426867563f Mon Sep 17 00:00:00 2001 From: sha1n Date: Fri, 16 Jan 2026 22:59:40 +0200 Subject: [PATCH 06/12] docs: add pkg.go.dev badge to README Add Go Reference badge linking to the official Go documentation on pkg.go.dev for better API discoverability. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 3ae542f..0704c3d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ [![Go](https://github.com/sha1n/termite/actions/workflows/go.yml/badge.svg)](https://github.com/sha1n/termite/actions/workflows/go.yml) +[![Go Reference](https://pkg.go.dev/badge/github.com/sha1n/termite.svg)](https://pkg.go.dev/github.com/sha1n/termite) ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/sha1n/termite) [![Go Report Card](https://goreportcard.com/badge/github.com/sha1n/termite)](https://goreportcard.com/report/github.com/sha1n/termite) [![Coverage Status](https://coveralls.io/repos/github/sha1n/termite/badge.svg?branch=master&service=github)](https://coveralls.io/github/sha1n/termite?branch=master) From 9acd231c645f74f4df88ee53f3c4aeccba48f656 Mon Sep 17 00:00:00 2001 From: sha1n Date: Fri, 16 Jan 2026 23:00:35 +0200 Subject: [PATCH 07/12] docs: add Matrix component example to README Add usage example for the Matrix component, demonstrating how to create multi-row layouts for concurrent task progress. Updated table of contents to include Matrix section. --- README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/README.md b/README.md index 3ae542f..d7f9083 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ - [Examples](#examples) - [Spinner](#spinner) - [Progress Bar](#progress-bar) + - [Matrix](#matrix) - [Showcase](#showcase) # TERMite @@ -54,6 +55,30 @@ if tick, err := progressBar.Start(ctx); err == nil { } ``` +### Matrix +```go +ctx, cancel := context.WithCancel(context.Background()) +defer cancel() + +refreshInterval := time.Millisecond * 100 +matrix := termite.NewMatrix(termite.StdoutWriter, refreshInterval) +done := matrix.Start(ctx) + +// Allocate rows for concurrent tasks +rows := matrix.NewRange(3) +for i, row := range rows { + go func(idx int, r termite.MatrixRow) { + r.Update(fmt.Sprintf("Task %d: Running...", idx+1)) + doWork() + r.Update(fmt.Sprintf("Task %d: Done!", idx+1)) + }(i, row) +} + +// Wait for completion +cancel() +<-done +``` + ## Showcase The code for this demo can be found in [internal/main.go](https://github.com/sha1n/termite/blob/master/internal/main.go) (`go run -mod=readonly ./internal`). From b602670d5744b85d7524d219a4d2aced79a81554 Mon Sep 17 00:00:00 2001 From: sha1n Date: Fri, 16 Jan 2026 23:01:55 +0200 Subject: [PATCH 08/12] docs: populate GEMINI.md with project context Add project overview, key components table, build commands, testing conventions, and code style guidelines for AI coding assistants. Force added because GEMINI.md is ignored by global git settings. --- GEMINI.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 GEMINI.md diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..2eab491 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,28 @@ +# Termite - AI Agent Context + +## Project Overview +Terminal app utilities library for Go - provides progress bars, spinners, cursor control, and multi-line matrix layouts. + +## Key Components +| Component | File | Purpose | +|-----------|------|---------| +| **Matrix** | `matrix.go` | Multi-row terminal layout for concurrent tasks | +| **Spinner** | `spinner.go` | Animated progress indicator | +| **ProgressBar** | `progress_bar.go` | Horizontal progress bar | +| **Cursor** | `cursor.go` | Terminal cursor control | + +## Build & Verify +```bash +make format lint test # Run all checks +go run ./internal # Run demo +``` + +## Testing Conventions +- All tests use testify/assert +- Use `bytes.Buffer` to capture terminal output +- Tests verify behavior, not internal state + +## Code Style +- Interfaces defined before implementations +- Formatters allow customization of visual elements +- Context-based cancellation for all async operations From fe79bd3d8b6225fbe584423e4c29a9af9ad51a96 Mon Sep 17 00:00:00 2001 From: sha1n Date: Fri, 16 Jan 2026 23:02:33 +0200 Subject: [PATCH 09/12] test: add ProgressBar isDone edge case tests --- progress_bar_test.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/progress_bar_test.go b/progress_bar_test.go index 710b9e5..a0b521f 100644 --- a/progress_bar_test.go +++ b/progress_bar_test.go @@ -129,3 +129,18 @@ func TestProgressBarStartWithCancelledContext(t *testing.T) { assert.Equal(t, context.Canceled, err) assert.Nil(t, tick) } + +func TestProgressBarIsDoneEdgeCases(t *testing.T) { + t.Run("maxTicks 0", func(t *testing.T) { + bar := NewDefaultProgressBar(new(bytes.Buffer), 0, fakeTerminalWidthFn) + assert.True(t, bar.IsDone()) + assert.False(t, bar.Tick()) + }) + + t.Run("maxTicks 1", func(t *testing.T) { + bar := NewDefaultProgressBar(new(bytes.Buffer), 1, fakeTerminalWidthFn) + assert.False(t, bar.IsDone()) + assert.False(t, bar.Tick()) // 1st tick makes it 100% and returns false + assert.True(t, bar.IsDone()) + }) +} From f8b46e5e901b67a7fac233ddecd06288bf4cf976 Mon Sep 17 00:00:00 2001 From: sha1n Date: Fri, 16 Jan 2026 23:04:53 +0200 Subject: [PATCH 10/12] test: refactor spinner tests to table-driven pattern Refactored title-related and error-handling tests to use table-driven patterns for better maintainability. Switched to test.RandomString() from gommons for consistency with other tests. Removed duplicate test case. --- spinner_test.go | 157 ++++++++++++++++++++++++------------------------ 1 file changed, 78 insertions(+), 79 deletions(-) diff --git a/spinner_test.go b/spinner_test.go index 18adb8b..9d6c4d5 100644 --- a/spinner_test.go +++ b/spinner_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/sha1n/gommons/pkg/io" + "github.com/sha1n/gommons/pkg/test" "github.com/stretchr/testify/assert" ) @@ -46,34 +47,89 @@ func TestSpinnerCancellation(t *testing.T) { assertStoppedEventually(t, probedWriter, spin.(*spinner)) } -func TestSpinnerStartAlreadyRunning(t *testing.T) { - emulatedStdout := new(bytes.Buffer) - - spin := NewSpinner(emulatedStdout, "", interval, DefaultSpinnerFormatter()) - ctx, cancel := context.WithCancel(context.Background()) - _ = spin.Start(ctx) - defer cancel() - - err := spin.Start(ctx) - assert.Error(t, err) +func TestSpinnerTitles(t *testing.T) { + t.Run("InitialTitle", func(t *testing.T) { + expectedTitle := test.RandomString() + emulatedStdout := new(bytes.Buffer) + spin := NewSpinner(emulatedStdout, expectedTitle, interval, DefaultSpinnerFormatter()) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + _ = spin.Start(ctx) + + assertBufferEventuallyContains(t, emulatedStdout, expectedTitle) + }) + + t.Run("SetTitle", func(t *testing.T) { + expectedInitialTitle := test.RandomString() + expectedUpdatedTitle := test.RandomString() + emulatedStdout := new(bytes.Buffer) + spin := NewSpinner(emulatedStdout, expectedInitialTitle, interval, DefaultSpinnerFormatter()) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + _ = spin.Start(ctx) + + assertBufferEventuallyContains(t, emulatedStdout, expectedInitialTitle) + assert.NoError(t, spin.SetTitle(expectedUpdatedTitle)) + assertBufferEventuallyContains(t, emulatedStdout, expectedUpdatedTitle) + }) + + t.Run("SetTitleOnStoppedSpinner", func(t *testing.T) { + spin := NewSpinner(new(bytes.Buffer), "title", interval, DefaultSpinnerFormatter()) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + _ = spin.Start(ctx) + _ = spin.Stop("") + + assert.Error(t, spin.SetTitle("new title")) + }) } -func TestSpinnerStopAlreadyStopped(t *testing.T) { - emulatedStdout := new(bytes.Buffer) - - spin := NewSpinner(emulatedStdout, "", interval, DefaultSpinnerFormatter()) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - _ = spin.Start(ctx) - - err := spin.Stop("") - assert.NoError(t, err) +func TestSpinnerErrors(t *testing.T) { + tests := []struct { + name string + run func(t *testing.T) + }{ + { + name: "StartAlreadyRunning", + run: func(t *testing.T) { + spin := NewSpinner(new(bytes.Buffer), "", interval, DefaultSpinnerFormatter()) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + _ = spin.Start(ctx) + assert.Error(t, spin.Start(ctx)) + }, + }, + { + name: "StopAlreadyStopped", + run: func(t *testing.T) { + spin := NewSpinner(new(bytes.Buffer), "", interval, DefaultSpinnerFormatter()) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + _ = spin.Start(ctx) + assert.NoError(t, spin.Stop("")) + assert.Error(t, spin.Stop("")) + }, + }, + { + name: "StartWithCancelledContext", + run: func(t *testing.T) { + spin := NewSpinner(new(bytes.Buffer), "", interval, DefaultSpinnerFormatter()) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := spin.Start(ctx) + assert.Error(t, err) + assert.Equal(t, context.Canceled, err) + }, + }, + } - assert.Error(t, spin.Stop(""), "expected error on second stop") + for _, tt := range tests { + t.Run(tt.name, tt.run) + } } func TestSpinnerStopMessage(t *testing.T) { - expectedStopMessage := generateRandomString() + expectedStopMessage := test.RandomString() emulatedStdout := new(bytes.Buffer) spin := NewSpinner(emulatedStdout, "", interval, DefaultSpinnerFormatter()) @@ -89,51 +145,6 @@ func TestSpinnerStopMessage(t *testing.T) { assert.NotContains(t, emulatedStdout.String(), "\n", "line feed is expected!") } -func TestSpinnerTitle(t *testing.T) { - expectedTitle := generateRandomString() - emulatedStdout := new(bytes.Buffer) - - spin := NewSpinner(emulatedStdout, expectedTitle, interval, DefaultSpinnerFormatter()) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - _ = spin.Start(ctx) - - assertBufferEventuallyContains(t, emulatedStdout, expectedTitle) -} - -func TestSpinnerSetTitle(t *testing.T) { - expectedInitialTitle := generateRandomString() - expectedUpdatedTitle := generateRandomString() - emulatedStdout := new(bytes.Buffer) - - spin := NewSpinner(emulatedStdout, expectedInitialTitle, interval, DefaultSpinnerFormatter()) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - _ = spin.Start(ctx) - - assertBufferEventuallyContains(t, emulatedStdout, expectedInitialTitle) - - assert.NoError(t, spin.SetTitle(expectedUpdatedTitle)) - - assertBufferEventuallyContains(t, emulatedStdout, expectedUpdatedTitle) -} - -func TestSpinnerSetTitleOnStoppedSpinner(t *testing.T) { - expectedInitialTitle := generateRandomString() - expectedUpdatedTitle := generateRandomString() - emulatedStdout := new(bytes.Buffer) - - spin := NewSpinner(emulatedStdout, expectedInitialTitle, interval, DefaultSpinnerFormatter()) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - _ = spin.Start(ctx) - - assertBufferEventuallyContains(t, emulatedStdout, expectedInitialTitle) - - assert.NoError(t, spin.Stop("")) - assert.Error(t, spin.SetTitle(expectedUpdatedTitle)) -} - func assertBufferEventuallyContains(t *testing.T, outBuffer *bytes.Buffer, expected string) { assert.Eventually( t, @@ -185,15 +196,3 @@ func stripControlCharacters(input string) string { return controlCharsRegex.ReplaceAllString(input, "") } - -func TestSpinnerStartWithCancelledContext(t *testing.T) { - emulatedStdout := new(bytes.Buffer) - - spin := NewSpinner(emulatedStdout, "", interval, DefaultSpinnerFormatter()) - ctx, cancel := context.WithCancel(context.Background()) - cancel() // Cancel before starting - - err := spin.Start(ctx) - assert.Error(t, err) - assert.Equal(t, context.Canceled, err) -} From 320bce2a47354638cd320fb9102239ce3e618430 Mon Sep 17 00:00:00 2001 From: sha1n Date: Fri, 16 Jan 2026 23:06:16 +0200 Subject: [PATCH 11/12] test: add Matrix concurrent stress test Added a stress test to verify Matrix thread safety under concurrent row updates. Also synchronized test helper usage by switching to test.RandomString() across affected test files. --- matrix_test.go | 40 ++++++++++++++++++++++++++++++---------- stdio_test.go | 5 +++-- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/matrix_test.go b/matrix_test.go index c3696de..f58a041 100644 --- a/matrix_test.go +++ b/matrix_test.go @@ -3,17 +3,16 @@ package termite import ( "bytes" "context" - "fmt" - "math/rand" "strings" "testing" "time" + "github.com/sha1n/gommons/pkg/test" "github.com/stretchr/testify/assert" ) func TestMatrixWritesToTerminalOutput(t *testing.T) { - example := generateRandomString() + example := test.RandomString() matrix, cancel := startNewMatrix() defer cancel() @@ -32,7 +31,7 @@ func TestMatrixUpdatesTerminalOutput(t *testing.T) { matrix.NewRow().Update(examples[0]) row2 := matrix.NewRow() row2.WriteString(examples[1]) - examples[1] = generateRandomString() + examples[1] = test.RandomString() matrix.NewRow().Update(examples[2]) row2.WriteString(examples[1]) @@ -40,7 +39,7 @@ func TestMatrixUpdatesTerminalOutput(t *testing.T) { } func TestMatrixRowUpdateTrimsLineFeeds(t *testing.T) { - expected := generateRandomString() + expected := test.RandomString() matrix, cancel := startNewMatrix() defer cancel() @@ -117,6 +116,31 @@ func TestMatrixGetRowByIdWithIllegalValue(t *testing.T) { assert.Error(t, err) } +func TestMatrixConcurrentStress(t *testing.T) { + matrix, cancel := startNewMatrix() + defer cancel() + + count := 100 + rows := matrix.NewRange(count) + startC := make(chan struct{}) + doneC := make(chan struct{}) + + for i := 0; i < count; i++ { + go func(row MatrixRow) { + <-startC + for j := 0; j < 100; j++ { + row.Update(test.RandomString()) + } + doneC <- struct{}{} + }(rows[i]) + } + + close(startC) + for i := 0; i < count; i++ { + <-doneC + } +} + func assertEventualSequence(t *testing.T, matrix Matrix, expected string) { contantsAllExamplesInOrderFn := func() bool { return strings.Contains( @@ -154,15 +178,11 @@ func startNewMatrix() (Matrix, context.CancelFunc) { return matrix, cancel } -func generateRandomString() string { - return fmt.Sprintf("[%d]", rand.Intn(time.Now().Nanosecond())) -} - func generateMultiLineExamples(count int) []string { examples := []string{} for i := 0; i < count; i++ { - examples = append(examples, generateRandomString()) + examples = append(examples, test.RandomString()) } return examples diff --git a/stdio_test.go b/stdio_test.go index f95771b..531d775 100644 --- a/stdio_test.go +++ b/stdio_test.go @@ -4,6 +4,7 @@ import ( "bytes" "testing" + "github.com/sha1n/gommons/pkg/test" "github.com/stretchr/testify/assert" ) @@ -21,7 +22,7 @@ func TestNewAutoFlushingWriter(t *testing.T) { // and has been introduced to reproduce and solve a bug. func TestWriteString(t *testing.T) { buf := new(bytes.Buffer) - example := generateRandomString() + example := test.RandomString() expected := []byte(example) writer := NewAutoFlushingWriter(buf) @@ -31,5 +32,5 @@ func TestWriteString(t *testing.T) { } func randomBytes() []byte { - return []byte(generateRandomString()) + return []byte(test.RandomString()) } From 1fa0555d5f65471377127eea0fbaaf83a9708946 Mon Sep 17 00:00:00 2001 From: sha1n Date: Fri, 16 Jan 2026 23:06:57 +0200 Subject: [PATCH 12/12] feat: add fluent builder for Spinner Introduced SpinnerBuilder to provide a more flexible and readable way to configure Spinner instances. Updated tests to verify the builder. --- spinner.go | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ spinner_test.go | 17 +++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/spinner.go b/spinner.go index a070a8b..e490b47 100644 --- a/spinner.go +++ b/spinner.go @@ -60,6 +60,15 @@ type Spinner interface { SetTitle(title string) error } +// SpinnerBuilder follows the builder pattern for creating a Spinner. +type SpinnerBuilder interface { + WithWriter(writer io.Writer) SpinnerBuilder + WithTitle(title string) SpinnerBuilder + WithInterval(interval time.Duration) SpinnerBuilder + WithFormatter(formatter SpinnerFormatter) SpinnerBuilder + Build() Spinner +} + type spinner struct { writer io.Writer interval time.Duration @@ -90,6 +99,46 @@ func NewDefaultSpinner() Spinner { return NewSpinner(StdoutWriter, "", time.Millisecond*100, DefaultSpinnerFormatter()) } +type spinnerBuilder struct { + writer io.Writer + title string + interval time.Duration + formatter SpinnerFormatter +} + +// NewSpinnerBuilder creates a new SpinnerBuilder with default values. +func NewSpinnerBuilder() SpinnerBuilder { + return &spinnerBuilder{ + writer: StdoutWriter, + interval: time.Millisecond * 100, + formatter: DefaultSpinnerFormatter(), + } +} + +func (b *spinnerBuilder) WithWriter(writer io.Writer) SpinnerBuilder { + b.writer = writer + return b +} + +func (b *spinnerBuilder) WithTitle(title string) SpinnerBuilder { + b.title = title + return b +} + +func (b *spinnerBuilder) WithInterval(interval time.Duration) SpinnerBuilder { + b.interval = interval + return b +} + +func (b *spinnerBuilder) WithFormatter(formatter SpinnerFormatter) SpinnerBuilder { + b.formatter = formatter + return b +} + +func (b *spinnerBuilder) Build() Spinner { + return NewSpinner(b.writer, b.title, b.interval, b.formatter) +} + func (s *spinner) writeString(str string) (n int, err error) { return io.WriteString(s.writer, str) } diff --git a/spinner_test.go b/spinner_test.go index 9d6c4d5..6e716b5 100644 --- a/spinner_test.go +++ b/spinner_test.go @@ -47,6 +47,23 @@ func TestSpinnerCancellation(t *testing.T) { assertStoppedEventually(t, probedWriter, spin.(*spinner)) } +func TestSpinnerBuilder(t *testing.T) { + expectedTitle := test.RandomString() + expectedInterval := time.Millisecond * 123 + emulatedStdout := new(bytes.Buffer) + + spin := NewSpinnerBuilder(). + WithWriter(emulatedStdout). + WithTitle(expectedTitle). + WithInterval(expectedInterval). + Build() + + s := spin.(*spinner) + assert.Equal(t, emulatedStdout, s.writer) + assert.Equal(t, expectedTitle, s.title) + assert.Equal(t, expectedInterval, s.interval) +} + func TestSpinnerTitles(t *testing.T) { t.Run("InitialTitle", func(t *testing.T) { expectedTitle := test.RandomString()