aboutsummaryrefslogtreecommitdiff
path: root/cmd/krino/colour_test.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-14 11:05:13 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-14 11:05:13 +0200
commitadc3410395771609d2db5ee5ae2b9da71115c5ca (patch)
tree453def41e18f607332be1088920f9d0c563603a3 /cmd/krino/colour_test.go
parent0468ce38470aa3ae8092b92d4f77e72d25dfa108 (diff)
downloadkrino-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.go164
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)
+ }
+ }
+}