mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
Drop additions that the UX plan does not ask for: - the interactive label edit form; label edit now requires a flag - label clone and the skip mode of label creation it needed - repo rename rewriting the Git remote or tea.repository; it now warns that the remote still points to the old name - the extra "source" remote on clones of pull mirrors - run artifact download, which duplicates run download --zip - 256-color and 24-bit label swatches; colors stay ANSI 16 - config list --show-source (config get keeps it) - the membership and access GETs before team member/repo add and remove; Gitea's PUT and DELETE are idempotent label edit/delete keep --by-name: it is the only way to address a label whose name is a number that is also another label's ID. Signed-off-by: bircni <bircni@icloud.com>
165 lines
6.4 KiB
Go
165 lines
6.4 KiB
Go
// 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\u009b31mtxt.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\\ bug \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",
|
||
},
|
||
{
|
||
"leaves hex colors plain",
|
||
nil,
|
||
[][]Cell{{{Text: "bug", Color: "#ff0000"}, {Text: "old", Muted: true}}},
|
||
Display{TTY: true, Color: true},
|
||
"bug \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 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\u009b31mit\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))
|
||
})
|
||
}
|
||
}
|
||
|
||
// row returns unstyled cells.
|
||
func row(texts ...string) []Cell {
|
||
cells := make([]Cell, len(texts))
|
||
for i, text := range texts {
|
||
cells[i].Text = text
|
||
}
|
||
return cells
|
||
}
|