From f5283cc7fce6a77c3d3cfd4d60c1acc17a2b25af Mon Sep 17 00:00:00 2001 From: Alik Send Date: Thu, 5 Dec 2024 16:34:42 -0600 Subject: [PATCH 1/6] Implement function to prune string keeping HTML closing tags Fixes #1587 --- backend/app/notify/telegram.go | 113 +++++++++++++++++++++++++++- backend/app/notify/telegram_test.go | 9 +++ 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/backend/app/notify/telegram.go b/backend/app/notify/telegram.go index 472b5c80f7..fbd5ec3e23 100644 --- a/backend/app/notify/telegram.go +++ b/backend/app/notify/telegram.go @@ -3,13 +3,17 @@ package notify import ( "context" "fmt" + "strings" "time" log "github.com/go-pkgz/lgr" ntf "github.com/go-pkgz/notify" "github.com/hashicorp/go-multierror" + "golang.org/x/net/html" ) +const comment_text_length_limit = 100 + // TelegramParams contain settings for telegram notifications type TelegramParams struct { AdminChannelID string // unique identifier for the target chat or username of the target channel (in the format @channelusername) @@ -75,6 +79,113 @@ func (t *Telegram) Send(ctx context.Context, req Request) error { return result.ErrorOrNil() } +type stringArr struct { + data []string + len int +} + +// Push adds element to the end +func (s *stringArr) Push(v string) { + s.data = append(s.data, v) + s.len += len(v) +} + +// Pop removes element from end and returns it +func (s *stringArr) Pop() string { + l := len(s.data) + newData, v := s.data[:l-1], s.data[l-1] + s.data = newData + s.len -= len(v) + return v +} + +// Unshift adds element to the start +func (s *stringArr) Unshift(v string) { + s.data = append([]string{v}, s.data...) + s.len += len(v) +} + +// Shift removes element from start and returns it +func (s *stringArr) Shift() string { + v, newData := s.data[0], s.data[1:] + s.data = newData + s.len -= len(v) + return v +} + +// String returns all strings concatenated +func (s stringArr) String() string { + return strings.Join(s.data, "") +} + +// Len returns total length of all strings concatenated +func (s stringArr) Len() int { + return s.len +} + +// pruneHTML prunes string keeping HTML closing tags +func pruneHTML(htmlText string, maxLength int) string { + result := stringArr{} + endTokens := stringArr{} + + suffix := "..." + suffixLen := len(suffix) + + tokenizer := html.NewTokenizer(strings.NewReader(htmlText)) + for { + if tokenizer.Next() == html.ErrorToken { + return result.String() + } + token := tokenizer.Token() + + switch token.Type { + case html.CommentToken, html.DoctypeToken: + // skip tokens without content + continue + + case html.StartTagToken: + // + // len(token) * 2 + len("<>") + totalLenToAppend := len(token.Data)*2 + 5 + if result.Len()+totalLenToAppend+endTokens.Len()+suffixLen > maxLength { + return result.String() + suffix + endTokens.String() + } + endTokens.Unshift(fmt.Sprintf("", token.Data)) + + case html.EndTagToken: + endTokens.Shift() + + case html.TextToken, html.SelfClosingTagToken: + if result.Len()+len(token.String())+endTokens.Len()+suffixLen > maxLength { + text := pruneStringToWord(token.String(), maxLength-result.Len()-endTokens.Len()-suffixLen) + return result.String() + text + suffix + endTokens.String() + } + } + + result.Push((token.String())) + } +} + +// pruneStringToWord prunes string to specified length respecting words +func pruneStringToWord(text string, maxLength int) string { + if maxLength <= 0 { + return "" + } + + result := "" + + arr := strings.Split(text, " ") + for _, s := range arr { + if len(result)+len(s) >= maxLength { + return strings.TrimRight(result, " ") + } + // keep last space, it's ok + result += s + " " + } + + return text +} + // buildMessage generates message for generic notification about new comment func (t *Telegram) buildMessage(req Request) string { commentURLPrefix := req.Comment.Locator.URL + uiNav @@ -85,7 +196,7 @@ func (t *Telegram) buildMessage(req Request) string { msg += fmt.Sprintf(" -> %s", commentURLPrefix+req.parent.ID, ntf.EscapeTelegramText(req.parent.User.Name)) } - msg += fmt.Sprintf("\n\n%s", ntf.TelegramSupportedHTML(req.Comment.Text)) + msg += fmt.Sprintf("\n\n%s", pruneHTML(ntf.TelegramSupportedHTML(req.Comment.Text), comment_text_length_limit)) if req.Comment.ParentID != "" { msg += fmt.Sprintf("\n\n\"%s\"", ntf.TelegramSupportedHTML(req.parent.Text)) diff --git a/backend/app/notify/telegram_test.go b/backend/app/notify/telegram_test.go index 4518e156f1..3439a4a9bd 100644 --- a/backend/app/notify/telegram_test.go +++ b/backend/app/notify/telegram_test.go @@ -53,6 +53,15 @@ some text HelloWorld`, res) + + // prune string keeping HTML closing tags + c = store.Comment{ + Text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + } + res = tb.buildMessage(Request{Comment: c}) + assert.Equal(t, ` + +Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do...`, res) } func TestTelegram_SendVerification(t *testing.T) { From cf636d422b1e8b8427c032ae6be13555635ffa0c Mon Sep 17 00:00:00 2001 From: Alik Send Date: Thu, 5 Dec 2024 18:11:21 -0600 Subject: [PATCH 2/6] change const name remove unneeded comment --- backend/app/notify/telegram.go | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/backend/app/notify/telegram.go b/backend/app/notify/telegram.go index fbd5ec3e23..972d43b4c6 100644 --- a/backend/app/notify/telegram.go +++ b/backend/app/notify/telegram.go @@ -12,7 +12,7 @@ import ( "golang.org/x/net/html" ) -const comment_text_length_limit = 100 +const commentTextLengthLimit = 100 // TelegramParams contain settings for telegram notifications type TelegramParams struct { @@ -147,16 +147,22 @@ func pruneHTML(htmlText string, maxLength int) string { // // len(token) * 2 + len("<>") totalLenToAppend := len(token.Data)*2 + 5 - if result.Len()+totalLenToAppend+endTokens.Len()+suffixLen > maxLength { + + lengthAfterChange := result.Len() + totalLenToAppend + endTokens.Len() + suffixLen + + if lengthAfterChange > maxLength { return result.String() + suffix + endTokens.String() } + endTokens.Unshift(fmt.Sprintf("", token.Data)) case html.EndTagToken: endTokens.Shift() case html.TextToken, html.SelfClosingTagToken: - if result.Len()+len(token.String())+endTokens.Len()+suffixLen > maxLength { + lengthAfterChange := result.Len() + len(token.String()) + endTokens.Len() + suffixLen + + if lengthAfterChange > maxLength { text := pruneStringToWord(token.String(), maxLength-result.Len()-endTokens.Len()-suffixLen) return result.String() + text + suffix + endTokens.String() } @@ -179,7 +185,6 @@ func pruneStringToWord(text string, maxLength int) string { if len(result)+len(s) >= maxLength { return strings.TrimRight(result, " ") } - // keep last space, it's ok result += s + " " } @@ -196,7 +201,7 @@ func (t *Telegram) buildMessage(req Request) string { msg += fmt.Sprintf(" -> %s", commentURLPrefix+req.parent.ID, ntf.EscapeTelegramText(req.parent.User.Name)) } - msg += fmt.Sprintf("\n\n%s", pruneHTML(ntf.TelegramSupportedHTML(req.Comment.Text), comment_text_length_limit)) + msg += fmt.Sprintf("\n\n%s", pruneHTML(ntf.TelegramSupportedHTML(req.Comment.Text), commentTextLengthLimit)) if req.Comment.ParentID != "" { msg += fmt.Sprintf("\n\n\"%s\"", ntf.TelegramSupportedHTML(req.parent.Text)) From c16c2bf09a33945296727bfa4eb86c57d4b7711f Mon Sep 17 00:00:00 2001 From: Alik Send Date: Mon, 16 Dec 2024 22:59:17 -0600 Subject: [PATCH 3/6] move pruneHTML to separated file --- backend/app/notify/prune_html.go | 122 +++++++++++++++++++++++++++++++ backend/app/notify/telegram.go | 116 ----------------------------- 2 files changed, 122 insertions(+), 116 deletions(-) create mode 100644 backend/app/notify/prune_html.go diff --git a/backend/app/notify/prune_html.go b/backend/app/notify/prune_html.go new file mode 100644 index 0000000000..e55a2fb8fc --- /dev/null +++ b/backend/app/notify/prune_html.go @@ -0,0 +1,122 @@ +package notify + +import ( + "fmt" + "strings" + + "golang.org/x/net/html" +) + +const commentTextLengthLimit = 100 + +type stringArr struct { + data []string + len int +} + +// Push adds element to the end +func (s *stringArr) Push(v string) { + s.data = append(s.data, v) + s.len += len(v) +} + +// Pop removes element from end and returns it +func (s *stringArr) Pop() string { + l := len(s.data) + newData, v := s.data[:l-1], s.data[l-1] + s.data = newData + s.len -= len(v) + return v +} + +// Unshift adds element to the start +func (s *stringArr) Unshift(v string) { + s.data = append([]string{v}, s.data...) + s.len += len(v) +} + +// Shift removes element from start and returns it +func (s *stringArr) Shift() string { + v, newData := s.data[0], s.data[1:] + s.data = newData + s.len -= len(v) + return v +} + +// String returns all strings concatenated +func (s stringArr) String() string { + return strings.Join(s.data, "") +} + +// Len returns total length of all strings concatenated +func (s stringArr) Len() int { + return s.len +} + +// pruneHTML prunes string keeping HTML closing tags +func pruneHTML(htmlText string, maxLength int) string { + result := stringArr{} + endTokens := stringArr{} + + suffix := "..." + suffixLen := len(suffix) + + tokenizer := html.NewTokenizer(strings.NewReader(htmlText)) + for { + if tokenizer.Next() == html.ErrorToken { + return result.String() + } + token := tokenizer.Token() + + switch token.Type { + case html.CommentToken, html.DoctypeToken: + // skip tokens without content + continue + + case html.StartTagToken: + // + // len(token) * 2 + len("<>") + totalLenToAppend := len(token.Data)*2 + 5 + + lengthAfterChange := result.Len() + totalLenToAppend + endTokens.Len() + suffixLen + + if lengthAfterChange > maxLength { + return result.String() + suffix + endTokens.String() + } + + endTokens.Unshift(fmt.Sprintf("", token.Data)) + + case html.EndTagToken: + endTokens.Shift() + + case html.TextToken, html.SelfClosingTagToken: + lengthAfterChange := result.Len() + len(token.String()) + endTokens.Len() + suffixLen + + if lengthAfterChange > maxLength { + text := pruneStringToWord(token.String(), maxLength-result.Len()-endTokens.Len()-suffixLen) + return result.String() + text + suffix + endTokens.String() + } + } + + result.Push((token.String())) + } +} + +// pruneStringToWord prunes string to specified length respecting words +func pruneStringToWord(text string, maxLength int) string { + if maxLength <= 0 { + return "" + } + + result := "" + + arr := strings.Split(text, " ") + for _, s := range arr { + if len(result)+len(s) >= maxLength { + return strings.TrimRight(result, " ") + } + result += s + " " + } + + return text +} diff --git a/backend/app/notify/telegram.go b/backend/app/notify/telegram.go index 972d43b4c6..9f3ea3462b 100644 --- a/backend/app/notify/telegram.go +++ b/backend/app/notify/telegram.go @@ -3,17 +3,13 @@ package notify import ( "context" "fmt" - "strings" "time" log "github.com/go-pkgz/lgr" ntf "github.com/go-pkgz/notify" "github.com/hashicorp/go-multierror" - "golang.org/x/net/html" ) -const commentTextLengthLimit = 100 - // TelegramParams contain settings for telegram notifications type TelegramParams struct { AdminChannelID string // unique identifier for the target chat or username of the target channel (in the format @channelusername) @@ -79,118 +75,6 @@ func (t *Telegram) Send(ctx context.Context, req Request) error { return result.ErrorOrNil() } -type stringArr struct { - data []string - len int -} - -// Push adds element to the end -func (s *stringArr) Push(v string) { - s.data = append(s.data, v) - s.len += len(v) -} - -// Pop removes element from end and returns it -func (s *stringArr) Pop() string { - l := len(s.data) - newData, v := s.data[:l-1], s.data[l-1] - s.data = newData - s.len -= len(v) - return v -} - -// Unshift adds element to the start -func (s *stringArr) Unshift(v string) { - s.data = append([]string{v}, s.data...) - s.len += len(v) -} - -// Shift removes element from start and returns it -func (s *stringArr) Shift() string { - v, newData := s.data[0], s.data[1:] - s.data = newData - s.len -= len(v) - return v -} - -// String returns all strings concatenated -func (s stringArr) String() string { - return strings.Join(s.data, "") -} - -// Len returns total length of all strings concatenated -func (s stringArr) Len() int { - return s.len -} - -// pruneHTML prunes string keeping HTML closing tags -func pruneHTML(htmlText string, maxLength int) string { - result := stringArr{} - endTokens := stringArr{} - - suffix := "..." - suffixLen := len(suffix) - - tokenizer := html.NewTokenizer(strings.NewReader(htmlText)) - for { - if tokenizer.Next() == html.ErrorToken { - return result.String() - } - token := tokenizer.Token() - - switch token.Type { - case html.CommentToken, html.DoctypeToken: - // skip tokens without content - continue - - case html.StartTagToken: - // - // len(token) * 2 + len("<>") - totalLenToAppend := len(token.Data)*2 + 5 - - lengthAfterChange := result.Len() + totalLenToAppend + endTokens.Len() + suffixLen - - if lengthAfterChange > maxLength { - return result.String() + suffix + endTokens.String() - } - - endTokens.Unshift(fmt.Sprintf("", token.Data)) - - case html.EndTagToken: - endTokens.Shift() - - case html.TextToken, html.SelfClosingTagToken: - lengthAfterChange := result.Len() + len(token.String()) + endTokens.Len() + suffixLen - - if lengthAfterChange > maxLength { - text := pruneStringToWord(token.String(), maxLength-result.Len()-endTokens.Len()-suffixLen) - return result.String() + text + suffix + endTokens.String() - } - } - - result.Push((token.String())) - } -} - -// pruneStringToWord prunes string to specified length respecting words -func pruneStringToWord(text string, maxLength int) string { - if maxLength <= 0 { - return "" - } - - result := "" - - arr := strings.Split(text, " ") - for _, s := range arr { - if len(result)+len(s) >= maxLength { - return strings.TrimRight(result, " ") - } - result += s + " " - } - - return text -} - // buildMessage generates message for generic notification about new comment func (t *Telegram) buildMessage(req Request) string { commentURLPrefix := req.Comment.Locator.URL + uiNav From f432c3eb5b003866fcfd2e3d550237dfd0536b85 Mon Sep 17 00:00:00 2001 From: Alik Send Date: Tue, 17 Dec 2024 17:35:52 -0600 Subject: [PATCH 4/6] move const back to telegram.go --- backend/app/notify/prune_html.go | 2 -- backend/app/notify/telegram.go | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/notify/prune_html.go b/backend/app/notify/prune_html.go index e55a2fb8fc..53b54ce8aa 100644 --- a/backend/app/notify/prune_html.go +++ b/backend/app/notify/prune_html.go @@ -7,8 +7,6 @@ import ( "golang.org/x/net/html" ) -const commentTextLengthLimit = 100 - type stringArr struct { data []string len int diff --git a/backend/app/notify/telegram.go b/backend/app/notify/telegram.go index 9f3ea3462b..796520ca93 100644 --- a/backend/app/notify/telegram.go +++ b/backend/app/notify/telegram.go @@ -10,6 +10,8 @@ import ( "github.com/hashicorp/go-multierror" ) +const commentTextLengthLimit = 100 + // TelegramParams contain settings for telegram notifications type TelegramParams struct { AdminChannelID string // unique identifier for the target chat or username of the target channel (in the format @channelusername) From 697426a78b015790d4e90298267287c311bfe670 Mon Sep 17 00:00:00 2001 From: Umputun Date: Wed, 18 Dec 2024 11:21:38 -0600 Subject: [PATCH 5/6] Add unit tests for string array manipulation and HTML pruning Introduce comprehensive test cases for stringArr methods (Push, Pop, Unshift, Shift, String) to ensure correct behavior and state management. Additionally, add tests for HTML pruning functions (pruneHTML, pruneStringToWord) to validate handling of length constraints and formatting scenarios. --- backend/app/notify/prune_html_test.go | 310 ++++++++++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 backend/app/notify/prune_html_test.go diff --git a/backend/app/notify/prune_html_test.go b/backend/app/notify/prune_html_test.go new file mode 100644 index 0000000000..15883e1ec2 --- /dev/null +++ b/backend/app/notify/prune_html_test.go @@ -0,0 +1,310 @@ +package notify + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStringArr_Push(t *testing.T) { + tests := []struct { + name string + initial []string + push string + expected []string + expLen int + }{ + { + name: "push to empty array", + initial: []string{}, + push: "hello", + expected: []string{"hello"}, + expLen: 5, + }, + { + name: "push to non-empty array", + initial: []string{"hello"}, + push: "world", + expected: []string{"hello", "world"}, + expLen: 10, + }, + { + name: "push empty string", + initial: []string{"hello"}, + push: "", + expected: []string{"hello", ""}, + expLen: 5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &stringArr{data: tt.initial, len: 0} + for _, str := range tt.initial { + s.len += len(str) + } + + s.Push(tt.push) + + assert.Equal(t, tt.expected, s.data) + assert.Equal(t, tt.expLen, s.len) + }) + } +} + +func TestStringArr_Pop(t *testing.T) { + tests := []struct { + name string + initial []string + expectedPop string + remaining []string + expLen int + }{ + { + name: "pop from array with multiple elements", + initial: []string{"hello", "world"}, + expectedPop: "world", + remaining: []string{"hello"}, + expLen: 5, + }, + { + name: "pop from array with one element", + initial: []string{"hello"}, + expectedPop: "hello", + remaining: []string{}, + expLen: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &stringArr{data: tt.initial, len: 0} + for _, str := range tt.initial { + s.len += len(str) + } + + popped := s.Pop() + + assert.Equal(t, tt.expectedPop, popped) + assert.Equal(t, tt.remaining, s.data) + assert.Equal(t, tt.expLen, s.len) + }) + } +} + +func TestStringArr_Unshift(t *testing.T) { + tests := []struct { + name string + initial []string + unshift string + expected []string + expLen int + }{ + { + name: "unshift to empty array", + initial: []string{}, + unshift: "hello", + expected: []string{"hello"}, + expLen: 5, + }, + { + name: "unshift to non-empty array", + initial: []string{"world"}, + unshift: "hello", + expected: []string{"hello", "world"}, + expLen: 10, + }, + { + name: "unshift empty string", + initial: []string{"world"}, + unshift: "", + expected: []string{"", "world"}, + expLen: 5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &stringArr{data: tt.initial, len: 0} + for _, str := range tt.initial { + s.len += len(str) + } + + s.Unshift(tt.unshift) + + assert.Equal(t, tt.expected, s.data) + assert.Equal(t, tt.expLen, s.len) + }) + } +} + +func TestStringArr_Shift(t *testing.T) { + tests := []struct { + name string + initial []string + expectedShift string + remaining []string + expLen int + }{ + { + name: "shift from array with multiple elements", + initial: []string{"hello", "world"}, + expectedShift: "hello", + remaining: []string{"world"}, + expLen: 5, + }, + { + name: "shift from array with one element", + initial: []string{"hello"}, + expectedShift: "hello", + remaining: []string{}, + expLen: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &stringArr{data: tt.initial, len: 0} + for _, str := range tt.initial { + s.len += len(str) + } + + shifted := s.Shift() + + assert.Equal(t, tt.expectedShift, shifted) + assert.Equal(t, tt.remaining, s.data) + assert.Equal(t, tt.expLen, s.len) + }) + } +} + +func TestStringArr_String(t *testing.T) { + tests := []struct { + name string + data []string + expected string + }{ + { + name: "empty array", + data: []string{}, + expected: "", + }, + { + name: "single element", + data: []string{"hello"}, + expected: "hello", + }, + { + name: "multiple elements", + data: []string{"hello", " ", "world"}, + expected: "hello world", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &stringArr{data: tt.data} + assert.Equal(t, tt.expected, s.String()) + }) + } +} + +func TestPruneHTML(t *testing.T) { + tests := []struct { + name string + html string + maxLength int + expected string + }{ + { + name: "simple text within limit", + html: "

Hello

", + maxLength: 20, + expected: "

Hello

", + }, + { + name: "text exceeding limit", + html: "

Hello world, this is a long text

", + maxLength: 15, + expected: "

...

", + }, + { + name: "nested tags within limit", + html: "

Hello

", + maxLength: 30, + expected: "

Hello

", + }, + { + name: "nested tags exceeding limit", + html: "

Hello world

More text

", + maxLength: 20, + expected: "
...
", + }, + { + name: "with comment", + html: "

Hello

", + maxLength: 20, + expected: "

Hello

", + }, + { + name: "self-closing tag", + html: "

Hello
World

", + maxLength: 20, + expected: "

Hello
...

", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := pruneHTML(tt.html, tt.maxLength) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestPruneStringToWord(t *testing.T) { + tests := []struct { + name string + text string + maxLength int + expected string + }{ + { + name: "within limit", + text: "hello world", + maxLength: 15, + expected: "hello world", + }, + { + name: "exact limit", + text: "hello world", + maxLength: 11, + expected: "hello", + }, + { + name: "cut at word boundary", + text: "hello world and more", + maxLength: 11, + expected: "hello", + }, + { + name: "zero length", + text: "hello", + maxLength: 0, + expected: "", + }, + { + name: "negative length", + text: "hello", + maxLength: -1, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := pruneStringToWord(tt.text, tt.maxLength) + assert.Equal(t, tt.expected, result) + }) + } +} From 2e1dda5aa36b14a0f67beecd663df1c51f05e934 Mon Sep 17 00:00:00 2001 From: Alik Send Date: Wed, 18 Dec 2024 15:18:17 -0600 Subject: [PATCH 6/6] Improve behavior --- backend/app/notify/prune_html.go | 2 +- backend/app/notify/prune_html_test.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/app/notify/prune_html.go b/backend/app/notify/prune_html.go index 53b54ce8aa..98ba556230 100644 --- a/backend/app/notify/prune_html.go +++ b/backend/app/notify/prune_html.go @@ -110,7 +110,7 @@ func pruneStringToWord(text string, maxLength int) string { arr := strings.Split(text, " ") for _, s := range arr { - if len(result)+len(s) >= maxLength { + if len(result)+len(s) > maxLength { return strings.TrimRight(result, " ") } result += s + " " diff --git a/backend/app/notify/prune_html_test.go b/backend/app/notify/prune_html_test.go index 15883e1ec2..29b09ca270 100644 --- a/backend/app/notify/prune_html_test.go +++ b/backend/app/notify/prune_html_test.go @@ -226,7 +226,7 @@ func TestPruneHTML(t *testing.T) { name: "text exceeding limit", html: "

Hello world, this is a long text

", maxLength: 15, - expected: "

...

", + expected: "

Hello...

", }, { name: "nested tags within limit", @@ -279,13 +279,13 @@ func TestPruneStringToWord(t *testing.T) { name: "exact limit", text: "hello world", maxLength: 11, - expected: "hello", + expected: "hello world", }, { name: "cut at word boundary", text: "hello world and more", maxLength: 11, - expected: "hello", + expected: "hello world", }, { name: "zero length",