Files
gitea-tea/internal/terminal/table_test.go
T
bircni 55574a9fd0 feat: fit list tables to the terminal and write TSV when piped
Lists had no header, wrapped every row once a title was longer than
the terminal, and were space-padded when piped, so cut -f and awk -F'\t'
could not split them and empty cells collapsed.

terminal.Table now takes column specs and a Display. On a terminal it
prints an uppercase header, aligns columns two spaces apart and shrinks
flexible columns, widest first, to Streams.Width (the terminal, then
$COLUMNS, then 80), cutting by display width with "…". States use the
terminal's ANSI 16 colors, hex colors fall back to the 256-color palette
unless COLORTERM advertises 24-bit, and OSC 8 links appear only on the
first column, independently of color (TEA_HYPERLINKS=auto|always|never).
Piped tables are tab-separated without header, color or links, and times
are RFC3339 UTC instead of "about 2 hours ago".

terminal.View lays out single resources (title, badges, metadata, fields,
body, footer URL) and renders markdown bodies with glamour on a terminal
only (TEA_MARKDOWN=raw keeps them raw). Shared formatters cover relative
times, timestamps, sizes, durations and status icons.

In the app, writeTable and writeView are the new list and view writers:
a "Showing N of M …" context line, an empty-state message on stderr and
the next-page hint. writeList, writeItem and batch keep working and now
render through the new table: tab-separated when piped, truncated at the
widest column on a terminal, with headers for issue list and pr list.

Signed-off-by: bircni <bircni@icloud.com>
2026-10-11 22:43:10 +02:00

165 lines
6.8 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package terminal
import (
"strings"
"testing"
"time"
"github.com/mattn/go-runewidth"
"github.com/stretchr/testify/require"
)
func TestTable(t *testing.T) {
now := time.Date(2026, 10, 11, 20, 0, 0, 0, time.UTC)
tty := Display{TTY: true, Now: now}
issues := []Column{{Header: "ID", Prefix: "#"}, {Header: "Title", Flex: true}, {Header: "Labels", Flex: true}, {Header: "Updated"}}
issueRows := [][]Cell{
{{Text: "12", Link: "https://example.org/12"}, {Text: "Emoji in title 🚀 works?"}, {}, {Time: now.Add(-2 * time.Hour)}},
{{Text: "1", Link: "https://example.org/1"}, {Text: "Login fails with special characters in password"}, {Text: "bug, priority/high"}, {Time: now.Add(-90 * time.Minute)}},
}
for _, tc := range []struct {
name string
columns []Column
rows [][]Cell
d Display
want string
}{
{"empty", issues, nil, tty, ""},
{"aligns legacy rows without header", nil, [][]Cell{Row("7", "Fix login", "open"), Row("12", "Crash", "closed")}, tty, "7 Fix login open\n12 Crash closed\n"},
{"counts runes, not bytes", nil, [][]Cell{Row("1", "äöü", "x"), Row("2", "abcd", "y")}, tty, "1 äöü x\n2 abcd y\n"},
{"counts wide runes twice", nil, [][]Cell{Row("1", "修复", "x"), Row("2", "abcd", "y")}, tty, "1 修复 x\n2 abcd y\n"},
{"trims empty trailing cells", nil, [][]Cell{Row("1", "bug", ""), Row("22", "feature", "new work")}, tty, "1 bug\n22 feature new work\n"},
{"pads ragged rows", nil, [][]Cell{Row("1"), Row("2", "two")}, tty, "1\n2 two\n"},
{
"heads columns and formats cells for people",
issues, issueRows, tty,
"ID TITLE LABELS UPDATED\n" +
"#12 Emoji in title 🚀 works? about 2 hours ago\n" +
"#1 Login fails with special characters in password bug, priority/high about 1 hour ago\n",
},
{
"shrinks the widest flexible column first",
issues, issueRows,
Display{TTY: true, Now: now, Width: 72},
"ID TITLE LABELS UPDATED\n" +
"#12 Emoji in title 🚀 works? about 2 hours ago\n" +
"#1 Login fails with special ch… bug, priority/high about 1 hour ago\n",
},
{
"keeps flexible columns at their floor",
issues, issueRows,
Display{TTY: true, Now: now, Width: 40},
"ID TITLE LABELS UPDATED\n" +
"#12 Emoji i… about 2 hours ago\n" +
"#1 Login f… bug, pr… about 1 hour ago\n",
},
{
"cuts wide runes by display width",
[]Column{{Header: "Title", Flex: true}, {Header: "By"}},
[][]Cell{Row("支持中文路径 (support CJK paths)", "alice")},
Display{TTY: true, Width: 20},
"TITLE BY\n支持中文路径… alice\n",
},
{
"writes tab-separated values when piped",
issues, issueRows,
Display{Width: 40, Color: true, Links: true},
"12\tEmoji in title 🚀 works?\t\t2026-10-11T18:00:00Z\n" +
"1\tLogin fails with special characters in password\tbug, priority/high\t2026-10-11T18:30:00Z\n",
},
{
"keeps columns to their output",
[]Column{{Header: "ID"}, {Header: "State", PipedOnly: true}, {Header: "Link", TTYOnly: true}},
[][]Cell{Row("1", "open", "→")},
tty,
"ID LINK\n1 →\n",
},
{
"keeps piped-only columns when piped",
[]Column{{Header: "ID"}, {Header: "State", PipedOnly: true}, {Header: "Link", TTYOnly: true}},
[][]Cell{Row("1", "open", "→")},
Display{},
"1\topen\n",
},
{
"strips hostile text in both outputs",
nil,
[][]Cell{Row("1", "a\x1b]52;c;cHduZWQ=\x07b\x1b[2J\u009b31m‮txt.exe\r\ntail\tend", "open")},
Display{},
"1\ta]52;c;cHduZWQ=b[2J31mtxt.exe tail end\topen\n",
},
{"ignores styles without color", nil, [][]Cell{{{Text: "7", Link: "https://example.org/7"}, {Text: "bug", Color: "ff0000"}, {Text: "open", Bold: true}}}, tty, "7 bug open\n"},
{
"styles after alignment and links only the first column",
[]Column{{Header: "ID"}, {Header: "Name"}, {Header: "State"}},
[][]Cell{{{Text: "7", Link: "https://example.org/7"}, {Text: "bug", Color: "ff0000", Link: "https://example.org/bug"}, {Text: "open", Color: "green", Bold: true}}, Row("12", "feature", "closed")},
Display{TTY: true, Color: true, Links: true},
"\x1b[90mID\x1b[0m \x1b[90mNAME\x1b[0m \x1b[90mSTATE\x1b[0m\n" +
"\x1b]8;;https://example.org/7\x1b\\7\x1b]8;;\x1b\\ \x1b[38;5;196mbug\x1b[0m \x1b[1m\x1b[32mopen\x1b[0m\x1b[0m\n" +
"12 feature closed\n",
},
{
"links without color",
nil,
[][]Cell{{{Text: "7", Link: "https://example.org/7"}, {Text: "bug", Muted: true}}},
Display{TTY: true, Links: true},
"\x1b]8;;https://example.org/7\x1b\\7\x1b]8;;\x1b\\ bug\n",
},
{
"writes 24-bit color on true-color terminals",
nil,
[][]Cell{{{Text: "bug", Color: "#ff0000"}, {Text: "old", Muted: true}}},
Display{TTY: true, Color: true, TrueColor: true},
"\x1b[38;2;255;0;0mbug\x1b[0m \x1b[90mold\x1b[0m\n",
},
} {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.want, Table(tc.columns, tc.rows, tc.d))
})
}
}
func TestTableFitsWidthWithWideRunes(t *testing.T) {
titles := []string{"Emoji in title 🚀 works?", "支持中文路径 (support CJK paths)", strings.Repeat("long ", 40)}
rows := make([][]Cell, len(titles))
for i, title := range titles {
rows[i] = Row("#1", title, "alice")
}
for _, width := range []int{30, 50, 80} {
for _, line := range strings.Split(strings.TrimSuffix(Table([]Column{{}, {Flex: true}, {}}, rows, Display{TTY: true, Width: width}), "\n"), "\n") {
require.LessOrEqual(t, runewidth.StringWidth(line), width, line)
}
}
}
func TestRecord(t *testing.T) {
record := Record{
Heading: []Cell{{Text: "#7", Bold: true}, {Text: "Crash\x1b[2J\nnow"}, {Text: "bob‮"}},
Lines: []string{"Steps:\r\n\x1b]52;c;aGk=\x07run\n", "https://example.org/7"},
}
require.Equal(t, "#7 Crash[2J now bob\nSteps:\n]52;c;aGk=run\n\nhttps://example.org/7\n", record.Render(false))
require.Equal(t, "\x1b[1m#7\x1b[0m Crash[2J now bob\nSteps:\n]52;c;aGk=run\n\nhttps://example.org/7\n", record.Render(true))
}
func TestSafeText(t *testing.T) {
for _, tc := range []struct{ name, in, text, line string }{
{"plain", "fix login", "fix login", "fix login"},
{"keeps tabs and line breaks", "a\tb\r\nc\nd", "a\tb\nc\nd", "a b c d"},
{"drops lone carriage returns", "safe\rFAKE", "safeFAKE", "safeFAKE"},
{
"strips hostile body",
"Steps:\r\n\x1b]52;c;cHduZWQ=\x07run\x1b[2J\u009b31m‮​it\x7f\n\x00done",
"Steps:\n]52;c;cHduZWQ=run[2J31mit\ndone",
"Steps: ]52;c;cHduZWQ=run[2J31mit done",
},
} {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.text, SafeText(tc.in))
require.Equal(t, tc.line, SafeLine(tc.in))
})
}
}