selection_test.go

  1package picker
  2
  3import (
  4	"testing"
  5
  6	"git.secluded.site/keld/internal/restic"
  7)
  8
  9// testSelection builds a Selection from a small set of LsNodes.
 10// Tree structure:
 11//
 12//	music/
 13//	  album-01/
 14//	    01-track.flac
 15//	    02-track.flac
 16//	  album-02/
 17//	    01-song.flac
 18//	docs/
 19//	  notes.txt
 20//	empty/
 21//	readme.txt
 22func testSelection(t *testing.T) *Selection {
 23	t.Helper()
 24	nodes := []restic.LsNode{
 25		{Name: "music", Type: "dir", Path: "/music"},
 26		{Name: "album-01", Type: "dir", Path: "/music/album-01"},
 27		{Name: "01-track.flac", Type: "file", Path: "/music/album-01/01-track.flac", Size: 1000},
 28		{Name: "02-track.flac", Type: "file", Path: "/music/album-01/02-track.flac", Size: 2000},
 29		{Name: "album-02", Type: "dir", Path: "/music/album-02"},
 30		{Name: "01-song.flac", Type: "file", Path: "/music/album-02/01-song.flac", Size: 3000},
 31		{Name: "docs", Type: "dir", Path: "/docs"},
 32		{Name: "notes.txt", Type: "file", Path: "/docs/notes.txt", Size: 500},
 33		{Name: "empty", Type: "dir", Path: "/empty"},
 34		{Name: "readme.txt", Type: "file", Path: "/readme.txt", Size: 100},
 35	}
 36	sfs := restic.NewSnapshotFS(nodes)
 37	return NewSelection(sfs)
 38}
 39
 40func TestSelectionInitialState(t *testing.T) {
 41	t.Parallel()
 42	sel := testSelection(t)
 43
 44	// Everything starts unselected.
 45	if sel.State(".") != CheckNone {
 46		t.Errorf("root: got %v, want CheckNone", sel.State("."))
 47	}
 48	if sel.State("music") != CheckNone {
 49		t.Errorf("music: got %v, want CheckNone", sel.State("music"))
 50	}
 51	if sel.State("readme.txt") != CheckNone {
 52		t.Errorf("readme.txt: got %v, want CheckNone", sel.State("readme.txt"))
 53	}
 54	if sel.AllSelected() {
 55		t.Error("AllSelected should be false initially")
 56	}
 57	if paths := sel.SelectedPaths(); len(paths) != 0 {
 58		t.Errorf("SelectedPaths: got %v, want empty", paths)
 59	}
 60}
 61
 62func TestSelectionToggleFile(t *testing.T) {
 63	t.Parallel()
 64	sel := testSelection(t)
 65
 66	sel.Toggle("readme.txt")
 67	if sel.State("readme.txt") != CheckAll {
 68		t.Errorf("readme.txt after toggle: got %v, want CheckAll", sel.State("readme.txt"))
 69	}
 70	// Root should be partial now.
 71	if sel.State(".") != CheckPartial {
 72		t.Errorf("root after file toggle: got %v, want CheckPartial", sel.State("."))
 73	}
 74
 75	// Toggle again to deselect.
 76	sel.Toggle("readme.txt")
 77	if sel.State("readme.txt") != CheckNone {
 78		t.Errorf("readme.txt after second toggle: got %v, want CheckNone", sel.State("readme.txt"))
 79	}
 80	if sel.State(".") != CheckNone {
 81		t.Errorf("root after deselect: got %v, want CheckNone", sel.State("."))
 82	}
 83}
 84
 85func TestSelectionToggleDirectory(t *testing.T) {
 86	t.Parallel()
 87	sel := testSelection(t)
 88
 89	// Selecting album-01 should select both tracks.
 90	sel.Toggle("music/album-01")
 91	if sel.State("music/album-01") != CheckAll {
 92		t.Errorf("album-01: got %v, want CheckAll", sel.State("music/album-01"))
 93	}
 94	if sel.State("music/album-01/01-track.flac") != CheckAll {
 95		t.Error("01-track.flac should be selected")
 96	}
 97	if sel.State("music/album-01/02-track.flac") != CheckAll {
 98		t.Error("02-track.flac should be selected")
 99	}
100	// music should be partial (album-02 is not selected).
101	if sel.State("music") != CheckPartial {
102		t.Errorf("music: got %v, want CheckPartial", sel.State("music"))
103	}
104
105	// Toggle album-01 again to deselect all.
106	sel.Toggle("music/album-01")
107	if sel.State("music/album-01") != CheckNone {
108		t.Errorf("album-01 after deselect: got %v, want CheckNone", sel.State("music/album-01"))
109	}
110	if sel.State("music") != CheckNone {
111		t.Errorf("music after deselect: got %v, want CheckNone", sel.State("music"))
112	}
113}
114
115func TestSelectionPartialThenToggle(t *testing.T) {
116	t.Parallel()
117	sel := testSelection(t)
118
119	// Select one track in album-01.
120	sel.Toggle("music/album-01/01-track.flac")
121	if sel.State("music/album-01") != CheckPartial {
122		t.Errorf("album-01: got %v, want CheckPartial", sel.State("music/album-01"))
123	}
124
125	// Toggling the partial directory should select ALL descendants.
126	sel.Toggle("music/album-01")
127	if sel.State("music/album-01") != CheckAll {
128		t.Errorf("album-01 after toggle from partial: got %v, want CheckAll", sel.State("music/album-01"))
129	}
130	if sel.State("music/album-01/02-track.flac") != CheckAll {
131		t.Error("02-track.flac should now be selected")
132	}
133}
134
135func TestSelectionEmptyDir(t *testing.T) {
136	t.Parallel()
137	sel := testSelection(t)
138
139	sel.Toggle("empty")
140	if sel.State("empty") != CheckAll {
141		t.Errorf("empty dir: got %v, want CheckAll", sel.State("empty"))
142	}
143
144	sel.Toggle("empty")
145	if sel.State("empty") != CheckNone {
146		t.Errorf("empty dir after deselect: got %v, want CheckNone", sel.State("empty"))
147	}
148}
149
150func TestSelectionAllSelected(t *testing.T) {
151	t.Parallel()
152	sel := testSelection(t)
153
154	// Select everything via root.
155	sel.Toggle(".")
156	if !sel.AllSelected() {
157		t.Error("AllSelected should be true after selecting root")
158	}
159	if sel.State(".") != CheckAll {
160		t.Errorf("root: got %v, want CheckAll", sel.State("."))
161	}
162
163	// SelectedPaths should return empty (full restore = no --include).
164	if paths := sel.SelectedPaths(); len(paths) != 0 {
165		t.Errorf("SelectedPaths when all selected: got %v, want empty", paths)
166	}
167
168	// Deselect root.
169	sel.Toggle(".")
170	if sel.AllSelected() {
171		t.Error("AllSelected should be false after deselecting root")
172	}
173}
174
175func TestSelectionSelectedPaths(t *testing.T) {
176	t.Parallel()
177	sel := testSelection(t)
178
179	// Select all of album-01 and the single file in docs.
180	sel.Toggle("music/album-01")
181	sel.Toggle("docs/notes.txt")
182
183	paths := sel.SelectedPaths()
184	// album-01 is fully selected → compressed to directory.
185	// docs has only one child (notes.txt), so selecting it makes docs
186	// fully selected → compressed to directory too.
187	want := map[string]bool{
188		"music/album-01": true,
189		"docs":           true,
190	}
191	if len(paths) != len(want) {
192		t.Fatalf("SelectedPaths: got %v, want keys of %v", paths, want)
193	}
194	for _, p := range paths {
195		if !want[p] {
196			t.Errorf("unexpected path %q in SelectedPaths", p)
197		}
198	}
199}
200
201func TestSelectionSelectedPathsCompression(t *testing.T) {
202	t.Parallel()
203	sel := testSelection(t)
204
205	// Select all of music (both albums).
206	sel.Toggle("music")
207
208	paths := sel.SelectedPaths()
209	// Should compress to just "music".
210	if len(paths) != 1 || paths[0] != "music" {
211		t.Errorf("SelectedPaths: got %v, want [music]", paths)
212	}
213}
214
215func TestSelectionDeselectChildMakesParentPartial(t *testing.T) {
216	t.Parallel()
217	sel := testSelection(t)
218
219	// Select entire music tree, then deselect one track.
220	sel.Toggle("music")
221	sel.Toggle("music/album-01/01-track.flac")
222
223	if sel.State("music/album-01") != CheckPartial {
224		t.Errorf("album-01: got %v, want CheckPartial", sel.State("music/album-01"))
225	}
226	if sel.State("music") != CheckPartial {
227		t.Errorf("music: got %v, want CheckPartial", sel.State("music"))
228	}
229
230	// SelectedPaths should list individual files, not the dir.
231	paths := sel.SelectedPaths()
232	pathSet := make(map[string]bool, len(paths))
233	for _, p := range paths {
234		pathSet[p] = true
235	}
236	// album-01 has only 02-track selected; album-02 is fully selected.
237	if !pathSet["music/album-01/02-track.flac"] {
238		t.Error("expected music/album-01/02-track.flac in paths")
239	}
240	if !pathSet["music/album-02"] {
241		t.Error("expected music/album-02 (fully selected) in paths")
242	}
243	if pathSet["music/album-01"] {
244		t.Error("music/album-01 should NOT be in paths (only partially selected)")
245	}
246	if pathSet["music"] {
247		t.Error("music should NOT be in paths (only partially selected)")
248	}
249}