diff --git a/cmd/display/tty.go b/cmd/display/tty.go index 04469cfd71..dd45ffd70a 100644 --- a/cmd/display/tty.go +++ b/cmd/display/tty.go @@ -21,9 +21,11 @@ import ( "fmt" "io" "iter" + "slices" "strings" "sync" "time" + "unicode/utf8" "github.com/buger/goterm" "github.com/docker/go-units" @@ -258,13 +260,39 @@ func (w *ttyWriter) childrenTasks(parent string) iter.Seq[*task] { } } +// lineData holds pre-computed formatting for a task line +type lineData struct { + spinner string // rendered spinner with color + prefix string // dry-run prefix if any + taskID string // possibly abbreviated + progress string // progress bar and size info + status string // rendered status with color + details string // possibly abbreviated + timer string // rendered timer with color + statusPad int // padding before status to align + timerPad int // padding before timer to align + statusColor colorFunc +} + func (w *ttyWriter) print() { + terminalWidth := goterm.Width() + terminalHeight := goterm.Height() + if terminalWidth <= 0 { + terminalWidth = 80 + } + if terminalHeight <= 0 { + terminalHeight = 24 + } + w.printWithDimensions(terminalWidth, terminalHeight) +} + +func (w *ttyWriter) printWithDimensions(terminalWidth, terminalHeight int) { w.mtx.Lock() defer w.mtx.Unlock() if len(w.tasks) == 0 { return } - terminalWidth := goterm.Width() + up := w.numLines + 1 if !w.repeated { up-- @@ -283,39 +311,208 @@ func (w *ttyWriter) print() { firstLine := fmt.Sprintf("[+] %s %d/%d", w.operation, numDone(w.tasks), len(w.tasks)) _, _ = fmt.Fprintln(w.out, firstLine) - var statusPadding int - for _, t := range w.tasks { - l := len(t.ID) - if len(t.parents) == 0 && statusPadding < l { - statusPadding = l + // Collect parent tasks in original order + allTasks := slices.Collect(w.parentTasks()) + + // Available lines: terminal height - 2 (header line + potential "more" line) + maxLines := terminalHeight - 2 + if maxLines < 1 { + maxLines = 1 + } + + showMore := len(allTasks) > maxLines + tasksToShow := allTasks + if showMore { + tasksToShow = allTasks[:maxLines-1] // Reserve one line for "more" message + } + + // collect line data and compute timerLen + lines := make([]lineData, len(tasksToShow)) + var timerLen int + for i, t := range tasksToShow { + lines[i] = w.prepareLineData(t) + if len(lines[i].timer) > timerLen { + timerLen = len(lines[i].timer) } } - skipChildEvents := len(w.tasks) > goterm.Height()-2 + // shorten details/taskID to fit terminal width + w.adjustLineWidth(lines, timerLen, terminalWidth) + + // compute padding + w.applyPadding(lines, terminalWidth, timerLen) + + // Render lines numLines := 0 - for t := range w.parentTasks() { - line := w.lineText(t, "", terminalWidth, statusPadding, w.dryRun) - _, _ = fmt.Fprint(w.out, line) + for _, l := range lines { + _, _ = fmt.Fprint(w.out, lineText(l)) numLines++ - if skipChildEvents { - continue - } - for child := range w.childrenTasks(t.ID) { - line := w.lineText(child, " ", terminalWidth, statusPadding-2, w.dryRun) - _, _ = fmt.Fprint(w.out, line) - numLines++ + } + + if showMore { + moreCount := len(allTasks) - len(tasksToShow) + moreText := fmt.Sprintf(" ... %d more", moreCount) + pad := terminalWidth - len(moreText) + if pad < 0 { + pad = 0 } + _, _ = fmt.Fprintf(w.out, "%s%s\n", moreText, strings.Repeat(" ", pad)) + numLines++ } + + // Clear any remaining lines from previous render for i := numLines; i < w.numLines; i++ { - if numLines < goterm.Height()-2 { - _, _ = fmt.Fprintln(w.out, strings.Repeat(" ", terminalWidth)) - numLines++ - } + _, _ = fmt.Fprintln(w.out, strings.Repeat(" ", terminalWidth)) + numLines++ } w.numLines = numLines } -func (w *ttyWriter) lineText(t *task, pad string, terminalWidth, statusPadding int, dryRun bool) string { +func (w *ttyWriter) applyPadding(lines []lineData, terminalWidth int, timerLen int) { + var maxBeforeStatus int + for i := range lines { + l := &lines[i] + // Width before statusPad: space(1) + spinner(1) + prefix + space(1) + taskID + progress + beforeStatus := 3 + lenAnsi(l.prefix) + utf8.RuneCountInString(l.taskID) + lenAnsi(l.progress) + if beforeStatus > maxBeforeStatus { + maxBeforeStatus = beforeStatus + } + } + + for i, l := range lines { + // Position before statusPad: space(1) + spinner(1) + prefix + space(1) + taskID + progress + beforeStatus := 3 + lenAnsi(l.prefix) + utf8.RuneCountInString(l.taskID) + lenAnsi(l.progress) + // statusPad aligns status; lineText adds 1 more space after statusPad + l.statusPad = maxBeforeStatus - beforeStatus + + // Format: beforeStatus + statusPad + space(1) + status + lineLen := beforeStatus + l.statusPad + 1 + utf8.RuneCountInString(l.status) + if l.details != "" { + lineLen += 1 + utf8.RuneCountInString(l.details) + } + l.timerPad = terminalWidth - lineLen - timerLen + if l.timerPad < 1 { + l.timerPad = 1 + } + lines[i] = l + + } +} + +func (w *ttyWriter) adjustLineWidth(lines []lineData, timerLen int, terminalWidth int) { + const minIDLen = 10 + maxStatusLen := maxStatusLength(lines) + + // Iteratively truncate until all lines fit + for range 100 { // safety limit + maxBeforeStatus := maxBeforeStatusWidth(lines) + overflow := computeOverflow(lines, maxBeforeStatus, maxStatusLen, timerLen, terminalWidth) + + if overflow <= 0 { + break + } + + // First try to truncate details, then taskID + if !truncateDetails(lines, overflow) && !truncateLongestTaskID(lines, overflow, minIDLen) { + break // Can't truncate further + } + } +} + +// maxStatusLength returns the maximum status text length across all lines. +func maxStatusLength(lines []lineData) int { + var maxLen int + for i := range lines { + if len(lines[i].status) > maxLen { + maxLen = len(lines[i].status) + } + } + return maxLen +} + +// maxBeforeStatusWidth computes the maximum width before statusPad across all lines. +// This is: space(1) + spinner(1) + prefix + space(1) + taskID + progress +func maxBeforeStatusWidth(lines []lineData) int { + var maxWidth int + for i := range lines { + l := &lines[i] + width := 3 + lenAnsi(l.prefix) + len(l.taskID) + lenAnsi(l.progress) + if width > maxWidth { + maxWidth = width + } + } + return maxWidth +} + +// computeOverflow calculates how many characters the widest line exceeds the terminal width. +// Returns 0 or negative if all lines fit. +func computeOverflow(lines []lineData, maxBeforeStatus, maxStatusLen, timerLen, terminalWidth int) int { + var maxOverflow int + for i := range lines { + l := &lines[i] + detailsLen := len(l.details) + if detailsLen > 0 { + detailsLen++ // space before details + } + // Line width: maxBeforeStatus + space(1) + status + details + minTimerPad(1) + timer + lineWidth := maxBeforeStatus + 1 + maxStatusLen + detailsLen + 1 + timerLen + overflow := lineWidth - terminalWidth + if overflow > maxOverflow { + maxOverflow = overflow + } + } + return maxOverflow +} + +// truncateDetails tries to truncate the first line's details to reduce overflow. +// Returns true if any truncation was performed. +func truncateDetails(lines []lineData, overflow int) bool { + for i := range lines { + l := &lines[i] + if len(l.details) > 3 { + reduction := overflow + if reduction > len(l.details)-3 { + reduction = len(l.details) - 3 + } + l.details = l.details[:len(l.details)-reduction-3] + "..." + return true + } else if l.details != "" { + l.details = "" + return true + } + } + return false +} + +// truncateLongestTaskID truncates the longest taskID to reduce overflow. +// Returns true if truncation was performed. +func truncateLongestTaskID(lines []lineData, overflow, minIDLen int) bool { + longestIdx := -1 + longestLen := minIDLen + for i := range lines { + if len(lines[i].taskID) > longestLen { + longestLen = len(lines[i].taskID) + longestIdx = i + } + } + + if longestIdx < 0 { + return false + } + + l := &lines[longestIdx] + reduction := overflow + 3 // account for "..." + newLen := len(l.taskID) - reduction + if newLen < minIDLen-3 { + newLen = minIDLen - 3 + } + if newLen > 0 { + l.taskID = l.taskID[:newLen] + "..." + } + return true +} + +func (w *ttyWriter) prepareLineData(t *task) lineData { endTime := time.Now() if t.status != api.Working { endTime = t.startTime @@ -323,8 +520,9 @@ func (w *ttyWriter) lineText(t *task, pad string, terminalWidth, statusPadding i endTime = t.endTime } } + prefix := "" - if dryRun { + if w.dryRun { prefix = PrefixColor(DRYRUN_PREFIX) } @@ -338,11 +536,9 @@ func (w *ttyWriter) lineText(t *task, pad string, terminalWidth, statusPadding i ) // only show the aggregated progress while the root operation is in-progress - if parent := t; parent.status == api.Working { - for child := range w.childrenTasks(parent.ID) { + if t.status == api.Working { + for child := range w.childrenTasks(t.ID) { if child.status == api.Working && child.total == 0 { - // we don't have totals available for all the child events - // so don't show the total progress yet hideDetails = true } total += child.total @@ -356,49 +552,49 @@ func (w *ttyWriter) lineText(t *task, pad string, terminalWidth, statusPadding i } } - // don't try to show detailed progress if we don't have any idea if total == 0 { hideDetails = true } - txt := t.ID + var progress string if len(completion) > 0 { - var progress string + progress = " [" + SuccessColor(strings.Join(completion, "")) + "]" if !hideDetails { - progress = fmt.Sprintf(" %7s / %-7s", units.HumanSize(float64(current)), units.HumanSize(float64(total))) + progress += fmt.Sprintf(" %7s / %-7s", units.HumanSize(float64(current)), units.HumanSize(float64(total))) } - txt = fmt.Sprintf("%s [%s]%s", - t.ID, - SuccessColor(strings.Join(completion, "")), - progress, - ) - } - textLen := len(txt) - padding := statusPadding - textLen - if padding < 0 { - padding = 0 - } - // calculate the max length for the status text, on errors it - // is 2-3 lines long and breaks the line formatting - maxDetailsLen := terminalWidth - textLen - statusPadding - 15 - details := t.details - // in some cases (debugging under VS Code), terminalWidth is set to zero by goterm.Width() ; ensuring we don't tweak strings with negative char index - if maxDetailsLen > 0 && len(details) > maxDetailsLen { - details = details[:maxDetailsLen] + "..." - } - text := fmt.Sprintf("%s %s%s %s %s%s %s", - pad, - spinner(t), - prefix, - txt, - strings.Repeat(" ", padding), - colorFn(t.status)(t.text), - details, - ) - timer := fmt.Sprintf("%.1fs ", elapsed) - o := align(text, TimerColor(timer), terminalWidth) + } + + return lineData{ + spinner: spinner(t), + prefix: prefix, + taskID: t.ID, + progress: progress, + status: t.text, + statusColor: colorFn(t.status), + details: t.details, + timer: fmt.Sprintf("%.1fs", elapsed), + } +} - return o +func lineText(l lineData) string { + var sb strings.Builder + sb.WriteString(" ") + sb.WriteString(l.spinner) + sb.WriteString(l.prefix) + sb.WriteString(" ") + sb.WriteString(l.taskID) + sb.WriteString(l.progress) + sb.WriteString(strings.Repeat(" ", l.statusPad)) + sb.WriteString(" ") + sb.WriteString(l.statusColor(l.status)) + if l.details != "" { + sb.WriteString(" ") + sb.WriteString(l.details) + } + sb.WriteString(strings.Repeat(" ", l.timerPad)) + sb.WriteString(TimerColor(l.timer)) + sb.WriteString("\n") + return sb.String() } var ( @@ -443,17 +639,6 @@ func numDone(tasks map[string]*task) int { return i } -func align(l, r string, w int) string { - ll := lenAnsi(l) - lr := lenAnsi(r) - pad := "" - count := w - ll - lr - if count > 0 { - pad = strings.Repeat(" ", count) - } - return fmt.Sprintf("%s%s%s\n", l, pad, r) -} - // lenAnsi count of user-perceived characters in ANSI string. func lenAnsi(s string) int { length := 0 diff --git a/cmd/display/tty_test.go b/cmd/display/tty_test.go new file mode 100644 index 0000000000..875f5f029f --- /dev/null +++ b/cmd/display/tty_test.go @@ -0,0 +1,424 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package display + +import ( + "bytes" + "strings" + "sync" + "testing" + "time" + "unicode/utf8" + + "gotest.tools/v3/assert" + + "github.com/docker/compose/v5/pkg/api" +) + +func newTestWriter() (*ttyWriter, *bytes.Buffer) { + var buf bytes.Buffer + w := &ttyWriter{ + out: &buf, + info: &buf, + tasks: map[string]*task{}, + done: make(chan bool), + mtx: &sync.Mutex{}, + operation: "pull", + } + return w, &buf +} + +func addTask(w *ttyWriter, id, text, details string, status api.EventStatus) { + t := &task{ + ID: id, + parents: make(map[string]struct{}), + startTime: time.Now(), + text: text, + details: details, + status: status, + spinner: NewSpinner(), + } + w.tasks[id] = t + w.ids = append(w.ids, id) +} + +// extractLines parses the output buffer and returns lines without ANSI control sequences +func extractLines(buf *bytes.Buffer) []string { + content := buf.String() + // Split by newline + rawLines := strings.Split(content, "\n") + var lines []string + for _, line := range rawLines { + // Skip empty lines and lines that are just ANSI codes + if lenAnsi(line) > 0 { + lines = append(lines, line) + } + } + return lines +} + +func TestPrintWithDimensions_LinesFitTerminalWidth(t *testing.T) { + testCases := []struct { + name string + taskID string + status string + details string + terminalWidth int + }{ + { + name: "short task fits wide terminal", + taskID: "Image foo", + status: "Pulling", + details: "layer abc123", + terminalWidth: 100, + }, + { + name: "long details truncated to fit", + taskID: "Image foo", + status: "Pulling", + details: "downloading layer sha256:abc123def456789xyz0123456789abcdef", + terminalWidth: 50, + }, + { + name: "long taskID truncated to fit", + taskID: "very-long-image-name-that-exceeds-terminal-width", + status: "Pulling", + details: "", + terminalWidth: 40, + }, + { + name: "both long taskID and details", + taskID: "my-very-long-service-name-here", + status: "Downloading", + details: "layer sha256:abc123def456789xyz0123456789", + terminalWidth: 50, + }, + { + name: "narrow terminal", + taskID: "service-name", + status: "Pulling", + details: "some details", + terminalWidth: 35, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + w, buf := newTestWriter() + addTask(w, tc.taskID, tc.status, tc.details, api.Working) + + w.printWithDimensions(tc.terminalWidth, 24) + + lines := extractLines(buf) + for i, line := range lines { + lineLen := lenAnsi(line) + assert.Assert(t, lineLen <= tc.terminalWidth, + "line %d has length %d which exceeds terminal width %d: %q", + i, lineLen, tc.terminalWidth, line) + } + }) + } +} + +func TestPrintWithDimensions_MultipleTasksFitTerminalWidth(t *testing.T) { + w, buf := newTestWriter() + + // Add multiple tasks with varying lengths + addTask(w, "Image nginx", "Pulling", "layer sha256:abc123", api.Working) + addTask(w, "Image postgres-database", "Pulling", "downloading", api.Working) + addTask(w, "Image redis", "Pulled", "", api.Done) + + terminalWidth := 60 + w.printWithDimensions(terminalWidth, 24) + + lines := extractLines(buf) + for i, line := range lines { + lineLen := lenAnsi(line) + assert.Assert(t, lineLen <= terminalWidth, + "line %d has length %d which exceeds terminal width %d: %q", + i, lineLen, terminalWidth, line) + } +} + +func TestPrintWithDimensions_VeryNarrowTerminal(t *testing.T) { + w, buf := newTestWriter() + addTask(w, "Image nginx", "Pulling", "details", api.Working) + + terminalWidth := 30 + w.printWithDimensions(terminalWidth, 24) + + lines := extractLines(buf) + for i, line := range lines { + lineLen := lenAnsi(line) + assert.Assert(t, lineLen <= terminalWidth, + "line %d has length %d which exceeds terminal width %d: %q", + i, lineLen, terminalWidth, line) + } +} + +func TestPrintWithDimensions_TaskWithProgress(t *testing.T) { + w, buf := newTestWriter() + + // Create parent task + parent := &task{ + ID: "Image nginx", + parents: make(map[string]struct{}), + startTime: time.Now(), + text: "Pulling", + status: api.Working, + spinner: NewSpinner(), + } + w.tasks["Image nginx"] = parent + w.ids = append(w.ids, "Image nginx") + + // Create child tasks to trigger progress display + for i := 0; i < 3; i++ { + child := &task{ + ID: "layer" + string(rune('a'+i)), + parents: map[string]struct{}{"Image nginx": {}}, + startTime: time.Now(), + text: "Downloading", + status: api.Working, + total: 1000, + current: 500, + percent: 50, + spinner: NewSpinner(), + } + w.tasks[child.ID] = child + w.ids = append(w.ids, child.ID) + } + + terminalWidth := 80 + w.printWithDimensions(terminalWidth, 24) + + lines := extractLines(buf) + for i, line := range lines { + lineLen := lenAnsi(line) + assert.Assert(t, lineLen <= terminalWidth, + "line %d has length %d which exceeds terminal width %d: %q", + i, lineLen, terminalWidth, line) + } +} + +func TestAdjustLineWidth_DetailsCorrectlyTruncated(t *testing.T) { + w := &ttyWriter{} + lines := []lineData{ + { + taskID: "Image foo", + status: "Pulling", + details: "downloading layer sha256:abc123def456789xyz", + }, + } + + terminalWidth := 50 + timerLen := 5 + w.adjustLineWidth(lines, timerLen, terminalWidth) + + // Verify the line fits + detailsLen := len(lines[0].details) + if detailsLen > 0 { + detailsLen++ // space before details + } + // widthWithoutDetails = 5 + prefix(0) + taskID(9) + progress(0) + status(7) + timer(5) = 26 + lineWidth := 5 + len(lines[0].taskID) + len(lines[0].status) + detailsLen + timerLen + + assert.Assert(t, lineWidth <= terminalWidth, + "line width %d should not exceed terminal width %d (taskID=%q, details=%q)", + lineWidth, terminalWidth, lines[0].taskID, lines[0].details) + + // Verify details were truncated (not removed entirely) + assert.Assert(t, lines[0].details != "", "details should be truncated, not removed") + assert.Assert(t, strings.HasSuffix(lines[0].details, "..."), "truncated details should end with ...") +} + +func TestAdjustLineWidth_TaskIDCorrectlyTruncated(t *testing.T) { + w := &ttyWriter{} + lines := []lineData{ + { + taskID: "very-long-image-name-that-exceeds-minimum-length", + status: "Pulling", + details: "", + }, + } + + terminalWidth := 40 + timerLen := 5 + w.adjustLineWidth(lines, timerLen, terminalWidth) + + lineWidth := 5 + len(lines[0].taskID) + 7 + timerLen + + assert.Assert(t, lineWidth <= terminalWidth, + "line width %d should not exceed terminal width %d (taskID=%q)", + lineWidth, terminalWidth, lines[0].taskID) + + assert.Assert(t, strings.HasSuffix(lines[0].taskID, "..."), "truncated taskID should end with ...") +} + +func TestAdjustLineWidth_NoTruncationNeeded(t *testing.T) { + w := &ttyWriter{} + originalDetails := "short" + originalTaskID := "Image foo" + lines := []lineData{ + { + taskID: originalTaskID, + status: "Pulling", + details: originalDetails, + }, + } + + // Wide terminal, nothing should be truncated + w.adjustLineWidth(lines, 5, 100) + + assert.Equal(t, originalTaskID, lines[0].taskID, "taskID should not be modified") + assert.Equal(t, originalDetails, lines[0].details, "details should not be modified") +} + +func TestAdjustLineWidth_DetailsRemovedWhenTooShort(t *testing.T) { + w := &ttyWriter{} + lines := []lineData{ + { + taskID: "Image foo", + status: "Pulling", + details: "abc", // Very short, can't be meaningfully truncated + }, + } + + // Terminal so narrow that even minimal details + "..." wouldn't help + w.adjustLineWidth(lines, 5, 28) + + assert.Equal(t, "", lines[0].details, "details should be removed entirely when too short to truncate") +} + +// stripAnsi removes ANSI escape codes from a string +func stripAnsi(s string) string { + var result strings.Builder + inAnsi := false + for _, r := range s { + if r == '\x1b' { + inAnsi = true + continue + } + if inAnsi { + // ANSI sequences end with a letter (m, h, l, G, etc.) + if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') { + inAnsi = false + } + continue + } + result.WriteRune(r) + } + return result.String() +} + +func TestPrintWithDimensions_PulledAndPullingWithLongIDs(t *testing.T) { + w, buf := newTestWriter() + + // Add a completed task with long ID + completedTask := &task{ + ID: "Image docker.io/library/nginx-long-name", + parents: make(map[string]struct{}), + startTime: time.Now().Add(-2 * time.Second), + endTime: time.Now(), + text: "Pulled", + status: api.Done, + spinner: NewSpinner(), + } + completedTask.spinner.Stop() + w.tasks[completedTask.ID] = completedTask + w.ids = append(w.ids, completedTask.ID) + + // Add a pending task with long ID + pendingTask := &task{ + ID: "Image docker.io/library/postgres-database", + parents: make(map[string]struct{}), + startTime: time.Now(), + text: "Pulling", + status: api.Working, + spinner: NewSpinner(), + } + w.tasks[pendingTask.ID] = pendingTask + w.ids = append(w.ids, pendingTask.ID) + + terminalWidth := 50 + w.printWithDimensions(terminalWidth, 24) + + // Strip all ANSI codes from output and split by newline + stripped := stripAnsi(buf.String()) + lines := strings.Split(stripped, "\n") + + // Filter non-empty lines + var nonEmptyLines []string + for _, line := range lines { + if strings.TrimSpace(line) != "" { + nonEmptyLines = append(nonEmptyLines, line) + } + } + + // Expected output format (50 runes per task line) + expected := `[+] pull 1/2 + ✔ Image docker.io/library/nginx-l... Pulled 2.0s + ⠋ Image docker.io/library/postgre... Pulling 0.0s` + + expectedLines := strings.Split(expected, "\n") + + // Debug output + t.Logf("Actual output:\n") + for i, line := range nonEmptyLines { + t.Logf(" line %d (%2d runes): %q", i, utf8.RuneCountInString(line), line) + } + + // Verify number of lines + assert.Equal(t, len(expectedLines), len(nonEmptyLines), "number of lines should match") + + // Verify each line matches expected + for i, line := range nonEmptyLines { + if i < len(expectedLines) { + assert.Equal(t, expectedLines[i], line, + "line %d should match expected", i) + } + } + + // Verify task lines fit within terminal width (strict - no tolerance) + for i, line := range nonEmptyLines { + if i > 0 { // Skip header line + runeCount := utf8.RuneCountInString(line) + assert.Assert(t, runeCount <= terminalWidth, + "line %d has %d runes which exceeds terminal width %d: %q", + i, runeCount, terminalWidth, line) + } + } +} + +func TestLenAnsi(t *testing.T) { + testCases := []struct { + input string + expected int + }{ + {"hello", 5}, + {"\x1b[32mhello\x1b[0m", 5}, + {"\x1b[1;32mgreen\x1b[0m text", 10}, + {"", 0}, + {"\x1b[0m", 0}, + } + + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + result := lenAnsi(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +}