diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-14 11:05:13 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-14 11:05:13 +0200 |
| commit | adc3410395771609d2db5ee5ae2b9da71115c5ca (patch) | |
| tree | 453def41e18f607332be1088920f9d0c563603a3 /cmd/krino/colour_test.go | |
| parent | 0468ce38470aa3ae8092b92d4f77e72d25dfa108 (diff) | |
| download | krino-adc3410395771609d2db5ee5ae2b9da71115c5ca.tar.gz krino-adc3410395771609d2db5ee5ae2b9da71115c5ca.zip | |
krino: coloured output, and --no-color
One palette type styles the plan table, warnings, headers, outcome counts,
prompt keys, undo's refused steps and krino log's (undone), from the
16-colour ANSI palette plus bold and faint only. Widths are measured on the
plain text, so columns line up; with colour off the output is unchanged.
--no-color works before or after any subcommand, as NO_COLOR does. The two
search-and-replace colourings are gone.
Diffstat (limited to 'cmd/krino/colour_test.go')
| -rw-r--r-- | cmd/krino/colour_test.go | 164 |
1 files changed, 164 insertions, 0 deletions
diff --git a/cmd/krino/colour_test.go b/cmd/krino/colour_test.go new file mode 100644 index 0000000..fa014e6 --- /dev/null +++ b/cmd/krino/colour_test.go @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "io" + "strings" + "testing" + + "krino/internal/plan" +) + +func TestPaletteZeroValueIsPlain(t *testing.T) { + var p palette + for _, got := range []string{p.bold("x"), p.faint("x"), p.warn("x"), p.rule("x"), p.alarm("x"), p.good("x"), p.bad("x")} { + if got != "x" { + t.Errorf("plain palette styled %q", got) + } + } + if got := p.keys("[a] apply all [q] quit"); got != "[a] apply all [q] quit" { + t.Errorf("plain keys = %q", got) + } +} + +func TestPaletteStylesWithAnsiSlotsOnly(t *testing.T) { + p := palette{on: true} + for _, tt := range []struct{ got, want string }{ + {p.bold("x"), "\x1b[1mx\x1b[0m"}, + {p.faint("x"), "\x1b[2mx\x1b[0m"}, + {p.bad("x"), "\x1b[31mx\x1b[0m"}, + {p.good("x"), "\x1b[32mx\x1b[0m"}, + {p.warn("x"), "\x1b[33mx\x1b[0m"}, + {p.rule("x"), "\x1b[34mx\x1b[0m"}, + {p.alarm("x"), "\x1b[1;31mx\x1b[0m"}, + {p.bold(""), ""}, + {p.keys("[a] apply all [q] quit"), "\x1b[1m[a]\x1b[0m apply all \x1b[1m[q]\x1b[0m quit"}, + } { + if tt.got != tt.want { + t.Errorf("got %q, want %q", tt.got, tt.want) + } + } +} + +func TestPadStyledPadsOnPlainWidth(t *testing.T) { + p := palette{on: true} + if got, want := padStyled("ab", 5, p.rule), "\x1b[34mab\x1b[0m "; got != want { + t.Errorf("got %q, want %q", got, want) + } + if got := padStyled("abcdef", 3, p.rule); got != "\x1b[34mabcdef\x1b[0m" { + t.Errorf("over-wide cell = %q", got) + } +} + +func TestColourOnHonoursNoColorFlag(t *testing.T) { + old := colourPolicy + t.Cleanup(func() { colourPolicy = old }) + colourPolicy = func(io.Writer) bool { return true } + if !colourOn(&globals{}, io.Discard) { + t.Error("colour off on a terminal without --no-color") + } + if colourOn(&globals{noColor: true}, io.Discard) { + t.Error("colour on despite --no-color") + } +} + +// TestNoColorFlagBeforeAndAfterSubcommand: --no-color parses in both +// positions, and survives a subcommand registering its own flags. +func TestNoColorFlagBeforeAndAfterSubcommand(t *testing.T) { + home(t) + if code, _, errOut := runCLI(t, "init"); code != 0 { + t.Fatal(errOut) + } + for _, args := range [][]string{{"--no-color", "check"}, {"check", "--no-color"}} { + if code, _, errOut := runCLI(t, args...); code != 0 { + t.Errorf("%v: exit %d: %s", args, code, errOut) + } + } + if !strings.Contains(usage, "--no-color") { + t.Error("usage does not mention --no-color") + } +} + +// TestPlanTableColouredAndAligned: with colour on, each element of the +// action table carries its style, and stripping the escapes gives exactly +// the plain rendering, so the columns line up. +func TestPlanTableColouredAndAligned(t *testing.T) { + root := "/r" + rows := []planRow{ + {num: "1", file: "a.pdf", step: plan.Step{Kind: plan.Move, Rule: "acme", Dst: "/r/Work/a.pdf", Reason: "type pdf"}}, + {step: plan.Step{Kind: plan.Trash, Rule: "old", Skip: "a duplicate is never deleted", Reason: "matched"}}, + {num: "2", file: "setup.deb", step: plan.Step{Kind: plan.DeletePermanent, Rule: "pkgs", Reason: "age > 90d"}}, + } + for i := range rows { + rows[i].actions = actionCell(rows[i].step, root) + rows[i].rule = rows[i].step.Rule + rows[i].reason = rows[i].step.Reason + } + var plain, coloured strings.Builder + printPlanTable(&plain, rows, palette{}) + printPlanTable(&coloured, rows, palette{on: true}) + for _, want := range []string{ + "\x1b[32mmove\x1b[0m", "\x1b[34macme\x1b[0m", "\x1b[2mtype pdf\x1b[0m", + "\x1b[2mtrash ", "\x1b[1;31mDELETE permanently\x1b[0m", + } { + if !strings.Contains(coloured.String(), want) { + t.Errorf("coloured table lacks %q:\n%q", want, coloured.String()) + } + } + if got := stripSGR(coloured.String()); got != plain.String() { + t.Errorf("stripped coloured table differs from plain:\n%s\nvs\n%s", got, plain.String()) + } +} + +func TestUndoAndLogColoured(t *testing.T) { + p := palette{on: true} + if got := colourRefused(" 1 x refused: gone\n", p); !strings.Contains(got, "\x1b[1;31mrefused:\x1b[0m") { + t.Errorf("refused not bold red: %q", got) + } + if got := colourRefused(" 1 x refused: gone\n", palette{}); got != " 1 x refused: gone\n" { + t.Errorf("plain palette changed the undo plan: %q", got) + } + if got := styleUndone("20260914T101203-ab12 2026-09-14 10:12 dl 3 moved (undone)", p); !strings.HasSuffix(got, " \x1b[2m(undone)\x1b[0m") { + t.Errorf("(undone) not faint: %q", got) + } + if got := styleUndone("20260914T101203-ab12 2026-09-14 10:12 dl 3 moved", p); strings.Contains(got, "\x1b[") { + t.Errorf("a run that is not undone was styled: %q", got) + } +} + +// stripSGR removes every ESC [ ... m sequence. +func stripSGR(s string) string { + var b strings.Builder + for i := 0; i < len(s); i++ { + if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' { + if j := strings.IndexByte(s[i:], 'm'); j > 0 { + i += j + continue + } + } + b.WriteByte(s[i]) + } + return b.String() +} + +// TestDryRunColouredAndNoColor drives the CLI with colour forced on: the +// header is bold and the warnings heading yellow; --no-color in either +// position gives no escape at all. NO_COLOR is tested in internal/tui, +// whose Colour colourPolicy is. +func TestDryRunColouredAndNoColor(t *testing.T) { + matchingFixture(t) + old := colourPolicy + t.Cleanup(func() { colourPolicy = old }) + colourPolicy = func(io.Writer) bool { return true } + _, out, _ := runCLI(t, "-n") + if !strings.Contains(out, "\x1b[1mkrino: dl ~/dl\x1b[0m") || !strings.Contains(out, "\x1b[33mwarnings\x1b[0m") { + t.Errorf("coloured dry run lacks bold header or yellow warnings:\n%q", out) + } + for _, args := range [][]string{{"--no-color", "-n"}, {"-n", "--no-color"}} { + _, out, _ := runCLI(t, args...) + if strings.Contains(out, "\x1b[") { + t.Errorf("%v printed an escape:\n%q", args, out) + } + } +} |
