summaryrefslogtreecommitdiff
path: root/cmd/krino/review_test.go
blob: 6bf5ea75c264ed6d2b27dc112b6981ff6bc43d96 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
// SPDX-License-Identifier: GPL-3.0-or-later

package main

import (
	"strings"
	"testing"

	"krino/internal/plan"
	"krino/internal/scan"
)

func chains(rels ...string) []plan.Chain {
	out := make([]plan.Chain, len(rels))
	for i, r := range rels {
		out[i] = plan.Chain{File: scan.File{Rel: r}, Steps: []plan.Step{{Kind: plan.Move, Dst: "/w/" + r}}}
	}
	return out
}

func TestChoosePerFile(t *testing.T) {
	// c enters per-file mode, then y n y for three files.
	approved, action, err := reviewChains(strings.NewReader("cyny"), new(strings.Builder), chains("a", "b", "c"), "", palette{})
	if err != nil {
		t.Fatal(err)
	}
	if action != 'c' {
		t.Errorf("action = %q", action)
	}
	if !approved["a"] || approved["b"] || !approved["c"] {
		t.Errorf("approved = %v; want a and c only", approved)
	}
}

func TestApplyAllAndSkip(t *testing.T) {
	approved, action, _ := reviewChains(strings.NewReader("a"), new(strings.Builder), chains("a", "b"), "", palette{})
	if action != 'a' || len(approved) != 2 {
		t.Errorf("[a] = %q %v; want every file approved", action, approved)
	}
	approved, action, _ = reviewChains(strings.NewReader("s"), new(strings.Builder), chains("a", "b"), "", palette{})
	if action != 's' || len(approved) != 0 {
		t.Errorf("[s] = %q %v; want nothing approved", action, approved)
	}
}

func TestPerFileDoneStopsAsking(t *testing.T) {
	// c, y for the first, then d: apply what was chosen so far.
	approved, _, _ := reviewChains(strings.NewReader("cyd"), new(strings.Builder), chains("a", "b", "c"), "", palette{})
	if !approved["a"] || approved["b"] || approved["c"] {
		t.Errorf("approved = %v; want only a", approved)
	}
}

// TestPerFileQuitAppliesNothing: spec §8.3's [q] on the per-file prompt is
// "quit, apply nothing" - stronger than [d], which keeps what was already
// chosen. It folds into the same top-level 'q' the caller already handles
// for the directory-level menu (spec §8.2's [q]), and discards even a file
// already marked yes.
func TestPerFileQuitAppliesNothing(t *testing.T) {
	approved, action, err := reviewChains(strings.NewReader("cyq"), new(strings.Builder), chains("a", "b"), "", palette{})
	if err != nil {
		t.Fatal(err)
	}
	if action != 'q' {
		t.Errorf("action = %q, want 'q'", action)
	}
	if len(approved) != 0 {
		t.Errorf("approved = %v; want nothing, even though a was marked yes first", approved)
	}
}

// TestPerFileYesToAllRemaining: spec §8.3's [a] mid-review approves the
// current file and every remaining one without asking again.
func TestPerFileYesToAllRemaining(t *testing.T) {
	approved, action, err := reviewChains(strings.NewReader("ca"), new(strings.Builder), chains("a", "b", "c"), "", palette{})
	if err != nil {
		t.Fatal(err)
	}
	if action != 'c' || len(approved) != 3 {
		t.Errorf("approved = %v action = %q; want all three approved", approved, action)
	}
}

// TestInvalidKeyReprompts: an unrecognised key at either the top-level menu
// or the per-file prompt does not abort the review - it is reported and the
// same prompt is read again.
func TestInvalidKeyReprompts(t *testing.T) {
	out := new(strings.Builder)
	approved, action, err := reviewChains(strings.NewReader("zs"), out, chains("a"), "", palette{})
	if err != nil {
		t.Fatal(err)
	}
	if action != 's' || len(approved) != 0 {
		t.Errorf("approved = %v action = %q; want [s] after the bad key", approved, action)
	}
	if !strings.Contains(out.String(), "z") {
		t.Errorf("no mention of the rejected key:\n%s", out)
	}
}

// TestPerFileDestinationIsRootRelative is review finding 1 (fix round
// 2026-09-12): reviewPerFile must render a destination the same way the
// directory-level table does (render.go's destText) - root-relative for a
// destination inside root, ~-abbreviated for one outside it - not always
// abbreviated because root was never passed through to actionCell at all.
// Both halves are pinned: getting only the "inside" half right would still
// let an outside-root destination silently regress to some other form.
func TestPerFileDestinationIsRootRelative(t *testing.T) {
	t.Setenv("HOME", "/home/x")
	root := "/home/x/dl"
	cs := []plan.Chain{
		{File: scan.File{Rel: "inside.txt"}, Steps: []plan.Step{{Kind: plan.Move, Dst: root + "/Work/Acme/inside.txt"}}},
		{File: scan.File{Rel: "outside.txt"}, Steps: []plan.Step{{Kind: plan.Move, Dst: "/home/x/backup/outside.txt"}}},
	}
	out := new(strings.Builder)
	if _, _, err := reviewChains(strings.NewReader("cyy"), out, cs, root, palette{}); err != nil {
		t.Fatal(err)
	}
	text := out.String()
	if !strings.Contains(text, "move    → Work/Acme/") {
		t.Errorf("destination inside root should be root-relative, not ~-abbreviated:\n%s", text)
	}
	if !strings.Contains(text, "move    → ~/backup/") {
		t.Errorf("destination outside root should be ~-abbreviated:\n%s", text)
	}
}