// 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) } } }