← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


Tests/Unit/FocusStateDataTests.swift (19022B)

1import Foundation
2import Testing
3
4#if os(macOS)
5@testable import Listless
6#else
7@testable import Listless_iOS
8#endif
9
10@Suite("FocusStateData Selection", .serialized)
11@MainActor
12struct FocusStateDataSelectionTests {
13
14    // Five stable IDs used across tests, representing display order.
15    static let ids = (0..<5).map { _ in UUID() }
16    static var displayOrder: [UUID] { ids }
17
18    // MARK: - selectedItemID (single-select setter/getter)
19
20    @Test("selectedItemID setter establishes single selection")
21    func selectedItemIDSetter() {
22        var state = FocusStateData()
23        let id = Self.ids[2]
24
25        state.selectedItemID = id
26
27        #expect(state.selectedItemID == id)
28        #expect(state.cursorItemID == id)
29        #expect(state.anchorItemID == id)
30        #expect(state.selectedItemIDs == [id])
31        #expect(state.inactiveSelections.isEmpty)
32    }
33
34    @Test("selectedItemID setter clears selection when set to nil")
35    func selectedItemIDSetterNil() {
36        var state = FocusStateData()
37        state.selectedItemID = Self.ids[0]
38
39        state.selectedItemID = nil
40
41        #expect(state.selectedItemID == nil)
42        #expect(state.cursorItemID == nil)
43        #expect(state.anchorItemID == nil)
44        #expect(state.selectedItemIDs.isEmpty)
45        #expect(state.inactiveSelections.isEmpty)
46    }
47
48    // MARK: - isItemSelected / hasMultipleSelection
49
50    @Test("isItemSelected returns true only for selected items")
51    func isItemSelected() {
52        var state = FocusStateData()
53        state.selectedItemID = Self.ids[1]
54
55        #expect(state.isItemSelected(Self.ids[1]))
56        #expect(!state.isItemSelected(Self.ids[0]))
57    }
58
59    @Test("hasMultipleSelection is false for single selection")
60    func hasMultipleSelectionSingle() {
61        var state = FocusStateData()
62        state.selectedItemID = Self.ids[0]
63
64        #expect(!state.hasMultipleSelection)
65    }
66
67    // MARK: - selectAll
68
69    @Test("selectAll selects every item in display order")
70    func selectAllBasic() {
71        var state = FocusStateData()
72        let order = Self.displayOrder
73
74        state.selectAll(displayOrder: order)
75
76        #expect(state.selectedItemIDs == Set(order))
77        #expect(state.anchorItemID == order.first)
78        #expect(state.cursorItemID == order.last)
79        #expect(state.inactiveSelections.isEmpty)
80        #expect(state.hasMultipleSelection)
81    }
82
83    @Test("selectAll with empty display order does nothing")
84    func selectAllEmpty() {
85        var state = FocusStateData()
86        state.selectedItemID = Self.ids[0]
87
88        state.selectAll(displayOrder: [])
89
90        #expect(state.selectedItemID == Self.ids[0])
91    }
92
93    @Test("selectAll with single item sets anchor and cursor to same item")
94    func selectAllSingleItem() {
95        var state = FocusStateData()
96        let single = [Self.ids[0]]
97
98        state.selectAll(displayOrder: single)
99
100        #expect(state.anchorItemID == Self.ids[0])
101        #expect(state.cursorItemID == Self.ids[0])
102        #expect(state.selectedItemIDs == Set(single))
103        #expect(!state.hasMultipleSelection)
104    }
105
106    @Test("selectAll clears inactive selections from prior Cmd+Click")
107    func selectAllClearsInactive() {
108        var state = FocusStateData()
109        let order = Self.displayOrder
110        // Build up inactive selections via toggleSelection.
111        state.toggleSelection(itemID: order[0], displayOrder: order)
112        state.toggleSelection(itemID: order[3], displayOrder: order)
113
114        state.selectAll(displayOrder: order)
115
116        #expect(state.inactiveSelections.isEmpty)
117        #expect(state.selectedItemIDs == Set(order))
118    }
119
120    // MARK: - toggleSelection (Cmd+Click)
121
122    @Test("Toggle adds an unselected item to the selection")
123    func toggleAddsItem() {
124        var state = FocusStateData()
125        let order = Self.displayOrder
126
127        state.toggleSelection(itemID: order[1], displayOrder: order)
128
129        #expect(state.isItemSelected(order[1]))
130        #expect(state.selectedItemIDs.count == 1)
131    }
132
133    @Test("Toggle removes a selected item from the selection")
134    func toggleRemovesItem() {
135        var state = FocusStateData()
136        let order = Self.displayOrder
137        state.toggleSelection(itemID: order[1], displayOrder: order)
138
139        state.toggleSelection(itemID: order[1], displayOrder: order)
140
141        #expect(!state.isItemSelected(order[1]))
142        #expect(state.selectedItemIDs.isEmpty)
143    }
144
145    @Test("Toggle sets anchor to item below toggled item")
146    func toggleAnchorBelowToggledItem() {
147        var state = FocusStateData()
148        let order = Self.displayOrder
149
150        state.toggleSelection(itemID: order[1], displayOrder: order)
151
152        // Item below index 1 is index 2.
153        #expect(state.anchorItemID == order[2])
154    }
155
156    @Test("Toggle at bottom of list sets anchor to self")
157    func toggleAnchorAtBottom() {
158        var state = FocusStateData()
159        let order = Self.displayOrder
160
161        state.toggleSelection(itemID: order[4], displayOrder: order)
162
163        #expect(state.anchorItemID == order[4])
164    }
165
166    @Test("Adding via toggle resets cursor to anchor")
167    func toggleAddResetsCursor() {
168        var state = FocusStateData()
169        let order = Self.displayOrder
170        state.toggleSelection(itemID: order[0], displayOrder: order)
171
172        state.toggleSelection(itemID: order[3], displayOrder: order)
173
174        // Toggling index 3 sets anchor to index 4; adding resets cursor to anchor.
175        #expect(state.cursorItemID == order[4])
176    }
177
178    @Test("Deselecting via toggle keeps cursor at its previous position")
179    func toggleDeselectKeepsCursor() {
180        var state = FocusStateData()
181        let order = Self.displayOrder
182        // Select two items.
183        state.toggleSelection(itemID: order[1], displayOrder: order)
184        state.toggleSelection(itemID: order[3], displayOrder: order)
185        let cursorBefore = state.cursorItemID
186
187        // Deselect one of them.
188        state.toggleSelection(itemID: order[1], displayOrder: order)
189
190        #expect(state.cursorItemID == cursorBefore)
191    }
192
193    @Test("Deselecting the last selected item clears all state")
194    func toggleDeselectLast() {
195        var state = FocusStateData()
196        let order = Self.displayOrder
197        state.toggleSelection(itemID: order[2], displayOrder: order)
198
199        state.toggleSelection(itemID: order[2], displayOrder: order)
200
201        #expect(state.selectedItemIDs.isEmpty)
202        #expect(state.anchorItemID == nil)
203        #expect(state.cursorItemID == nil)
204        #expect(state.inactiveSelections.isEmpty)
205    }
206
207    @Test("Toggle with ID not in display order is ignored")
208    func toggleUnknownID() {
209        var state = FocusStateData()
210        let unknown = UUID()
211
212        state.toggleSelection(itemID: unknown, displayOrder: Self.displayOrder)
213
214        #expect(state.selectedItemIDs.isEmpty)
215    }
216
217    @Test("Multiple toggles build discontinuous selection")
218    func toggleMultipleDiscontinuous() {
219        var state = FocusStateData()
220        let order = Self.displayOrder
221
222        state.toggleSelection(itemID: order[0], displayOrder: order)
223        state.toggleSelection(itemID: order[2], displayOrder: order)
224        state.toggleSelection(itemID: order[4], displayOrder: order)
225
226        #expect(state.isItemSelected(order[0]))
227        #expect(state.isItemSelected(order[2]))
228        #expect(state.isItemSelected(order[4]))
229        #expect(!state.isItemSelected(order[1]))
230        #expect(!state.isItemSelected(order[3]))
231        #expect(state.hasMultipleSelection)
232    }
233
234    // MARK: - extendSelection (Shift+Arrow)
235
236    @Test("Extend selection downward from anchor")
237    func extendDown() {
238        var state = FocusStateData()
239        let order = Self.displayOrder
240        state.selectedItemID = order[1]
241
242        state.extendSelection(to: order[3], displayOrder: order)
243
244        #expect(state.selectedItemIDs == Set(order[1...3]))
245        #expect(state.cursorItemID == order[3])
246        #expect(state.anchorItemID == order[1])
247    }
248
249    @Test("Extend selection upward from anchor")
250    func extendUp() {
251        var state = FocusStateData()
252        let order = Self.displayOrder
253        state.selectedItemID = order[3]
254
255        state.extendSelection(to: order[1], displayOrder: order)
256
257        #expect(state.selectedItemIDs == Set(order[1...3]))
258        #expect(state.cursorItemID == order[1])
259        #expect(state.anchorItemID == order[3])
260    }
261
262    @Test("Extend selection contracts when cursor moves back toward anchor")
263    func extendContract() {
264        var state = FocusStateData()
265        let order = Self.displayOrder
266        state.selectedItemID = order[1]
267
268        state.extendSelection(to: order[4], displayOrder: order)
269        state.extendSelection(to: order[2], displayOrder: order)
270
271        #expect(state.selectedItemIDs == Set(order[1...2]))
272        #expect(state.cursorItemID == order[2])
273    }
274
275    @Test("Extend to same position as anchor selects only anchor")
276    func extendToAnchor() {
277        var state = FocusStateData()
278        let order = Self.displayOrder
279        state.selectedItemID = order[2]
280
281        state.extendSelection(to: order[2], displayOrder: order)
282
283        #expect(state.selectedItemIDs == [order[2]])
284        #expect(state.cursorItemID == order[2])
285    }
286
287    @Test("Extend without anchor is ignored")
288    func extendWithoutAnchor() {
289        var state = FocusStateData()
290
291        state.extendSelection(to: Self.ids[2], displayOrder: Self.displayOrder)
292
293        #expect(state.selectedItemIDs.isEmpty)
294    }
295
296    @Test("Extend with unknown target ID is ignored")
297    func extendUnknownTarget() {
298        var state = FocusStateData()
299        state.selectedItemID = Self.ids[0]
300
301        state.extendSelection(to: UUID(), displayOrder: Self.displayOrder)
302
303        #expect(state.selectedItemIDs == [Self.ids[0]])
304    }
305
306    // MARK: - Cmd+Click then Shift+Arrow (inactive selection preservation)
307
308    @Test("Shift+Arrow after Cmd+Click preserves inactive selections")
309    func extendPreservesInactive() {
310        var state = FocusStateData()
311        let order = Self.displayOrder
312
313        // Cmd+Click items 0 and 4 to create a discontinuous selection.
314        state.toggleSelection(itemID: order[0], displayOrder: order)
315        state.toggleSelection(itemID: order[4], displayOrder: order)
316
317        // After toggling index 4, anchor is set to index 4 (last item).
318        // Shift+Arrow down to index 4 should preserve index 0 as inactive.
319        state.extendSelection(to: order[4], displayOrder: order)
320
321        #expect(state.isItemSelected(order[0]))
322        #expect(state.isItemSelected(order[4]))
323    }
324
325    @Test("Adjacent inactive selections merge into active range")
326    func mergeAdjacentInactive() {
327        var state = FocusStateData()
328        let order = Self.displayOrder
329
330        // Select items 0 and 2 via Cmd+Click.
331        state.toggleSelection(itemID: order[0], displayOrder: order)
332        state.toggleSelection(itemID: order[2], displayOrder: order)
333
334        // After toggling 0 then 2:
335        //   - toggleSelection(0): selected={0}, anchor=1, cursor=1
336        //   - toggleSelection(2): selected={0,2}, anchor=3, cursor=3
337        //     inactive = {0} (outside anchor(3)–cursor(3) range)
338
339        // Extend from anchor (3) up to 1 — active range is [1,2,3].
340        // Item 0 is adjacent to active range at index 1, so it merges.
341        state.extendSelection(to: order[1], displayOrder: order)
342
343        #expect(state.isItemSelected(order[0]))
344        #expect(state.isItemSelected(order[1]))
345        #expect(state.isItemSelected(order[2]))
346        #expect(state.isItemSelected(order[3]))
347        #expect(state.inactiveSelections.isEmpty)
348    }
349
350    @Test("Non-adjacent inactive selections stay inactive")
351    func nonAdjacentStaysInactive() {
352        var state = FocusStateData()
353        let order = Self.displayOrder
354
355        // Select items 0 and 4 via Cmd+Click.
356        state.toggleSelection(itemID: order[0], displayOrder: order)
357        state.toggleSelection(itemID: order[4], displayOrder: order)
358
359        // After toggling 0 then 4:
360        //   - toggleSelection(0): selected={0}, anchor=1, cursor=1
361        //   - toggleSelection(4): selected={0,4}, anchor=4, cursor=4
362        //     inactive = {0} (outside anchor(4)–cursor(4) range)
363
364        // Extend from anchor (4) to 3 — active range is [3,4].
365        // Item 0 is not adjacent to index 3, so stays inactive.
366        state.extendSelection(to: order[3], displayOrder: order)
367
368        #expect(state.isItemSelected(order[0]))
369        #expect(state.isItemSelected(order[3]))
370        #expect(state.isItemSelected(order[4]))
371        #expect(!state.isItemSelected(order[1]))
372        #expect(!state.isItemSelected(order[2]))
373        #expect(state.inactiveSelections.contains(order[0]))
374    }
375
376    // MARK: - Display order changes between operations
377
378    @Test("Prune then extend after item is removed from display order")
379    func pruneAndExtendAfterItemRemoved() {
380        var state = FocusStateData()
381        let order = Self.displayOrder
382
383        // Cmd+Click items 1 and 3.
384        state.toggleSelection(itemID: order[1], displayOrder: order)
385        state.toggleSelection(itemID: order[3], displayOrder: order)
386        // State: selected={1,3}, anchor=4, cursor=4, inactive={1,3}
387
388        // Simulate item 3 being deleted — prune stale IDs.
389        let reducedOrder = [order[0], order[1], order[2], order[4]]
390        state.pruneDeletedItems(displayOrder: reducedOrder)
391
392        // order[3] removed from selected and inactive; anchor/cursor (order[4]) still valid.
393        #expect(!state.isItemSelected(order[3]))
394        #expect(state.isItemSelected(order[1]))
395        #expect(state.anchorItemID == order[4])
396
397        // Extend from anchor (order[4]) toward order[2].
398        state.extendSelection(to: order[2], displayOrder: reducedOrder)
399
400        // Active range [order[2], order[4]]; order[1] is adjacent inactive → merges.
401        #expect(state.isItemSelected(order[1]))
402        #expect(state.isItemSelected(order[2]))
403        #expect(state.isItemSelected(order[4]))
404        #expect(!state.isItemSelected(order[3]))
405        #expect(state.inactiveSelections.isEmpty)
406    }
407
408    @Test("Prune then extend when anchor was the deleted item")
409    func pruneRemovedAnchorThenExtend() {
410        var state = FocusStateData()
411        let order = Self.displayOrder
412        state.selectedItemID = order[2]
413
414        // Remove the anchor/cursor item from display order.
415        let reducedOrder = [order[0], order[1], order[3], order[4]]
416        state.pruneDeletedItems(displayOrder: reducedOrder)
417
418        // Anchor and cursor pruned; selection is now empty.
419        #expect(state.selectedItemIDs.isEmpty)
420        #expect(state.anchorItemID == nil)
421        #expect(state.cursorItemID == nil)
422    }
423
424    @Test("Toggle after display order is reordered uses new positions")
425    func toggleWithReorderedDisplay() {
426        var state = FocusStateData()
427        let order = Self.displayOrder
428
429        // Reverse the display order (simulating a sort change).
430        let reversed = Array(order.reversed())
431
432        // Toggle item that was at index 4 in original, now at index 0.
433        state.toggleSelection(itemID: order[4], displayOrder: reversed)
434
435        // In reversed order, order[4] is at index 0, so "below" is index 1 = order[3].
436        #expect(state.anchorItemID == order[3])
437        #expect(state.isItemSelected(order[4]))
438    }
439
440    @Test("Prune after selectAll with deleted items cleans up selection")
441    func pruneAfterSelectAll() {
442        var state = FocusStateData()
443        let order = Self.displayOrder
444        state.selectAll(displayOrder: order)
445
446        // Items 0 and 2 deleted.
447        let shrunken = [order[1], order[3], order[4]]
448        state.pruneDeletedItems(displayOrder: shrunken)
449
450        #expect(state.selectedItemIDs == Set([order[1], order[3], order[4]]))
451        // Anchor (order[0]) was pruned; cursor (order[4]) survives.
452        #expect(state.anchorItemID == nil)
453        #expect(state.cursorItemID == order[4])
454    }
455
456    // MARK: - pruneDeletedItems
457
458    @Test("Prune with no deletions changes nothing")
459    func pruneNoOp() {
460        var state = FocusStateData()
461        let order = Self.displayOrder
462        state.toggleSelection(itemID: order[1], displayOrder: order)
463        state.toggleSelection(itemID: order[3], displayOrder: order)
464        let selectedBefore = state.selectedItemIDs
465        let inactiveBefore = state.inactiveSelections
466
467        state.pruneDeletedItems(displayOrder: order)
468
469        #expect(state.selectedItemIDs == selectedBefore)
470        #expect(state.inactiveSelections == inactiveBefore)
471    }
472
473    @Test("Prune removes ghost IDs from selectedItemIDs and inactiveSelections")
474    func pruneRemovesGhosts() {
475        var state = FocusStateData()
476        let order = Self.displayOrder
477        // Cmd+Click 0 and 2 to create inactive selection at 0.
478        state.toggleSelection(itemID: order[0], displayOrder: order)
479        state.toggleSelection(itemID: order[2], displayOrder: order)
480
481        // Delete item 0.
482        let reducedOrder = [order[1], order[2], order[3], order[4]]
483        state.pruneDeletedItems(displayOrder: reducedOrder)
484
485        #expect(!state.isItemSelected(order[0]))
486        #expect(!state.inactiveSelections.contains(order[0]))
487        #expect(state.isItemSelected(order[2]))
488    }
489
490    @Test("Prune with all items deleted clears everything")
491    func pruneAllDeleted() {
492        var state = FocusStateData()
493        let order = Self.displayOrder
494        state.selectAll(displayOrder: order)
495
496        state.pruneDeletedItems(displayOrder: [])
497
498        #expect(state.selectedItemIDs.isEmpty)
499        #expect(state.anchorItemID == nil)
500        #expect(state.cursorItemID == nil)
501        #expect(state.inactiveSelections.isEmpty)
502    }
503
504    @Test("Prune falls back cursor to anchor when cursor is deleted")
505    func pruneCursorFallsBackToAnchor() {
506        var state = FocusStateData()
507        let order = Self.displayOrder
508        state.selectedItemID = order[1]
509        state.extendSelection(to: order[3], displayOrder: order)
510        // anchor=order[1], cursor=order[3]
511
512        // Delete cursor item.
513        let reducedOrder = [order[0], order[1], order[2], order[4]]
514        state.pruneDeletedItems(displayOrder: reducedOrder)
515
516        #expect(state.cursorItemID == order[1])
517        #expect(state.anchorItemID == order[1])
518    }
519
520    // MARK: - selectedItemID setter resets multi-select
521
522    @Test("Setting selectedItemID collapses multi-select to single")
523    func setterResetsMultiSelect() {
524        var state = FocusStateData()
525        let order = Self.displayOrder
526        state.selectAll(displayOrder: order)
527
528        state.selectedItemID = order[2]
529
530        #expect(state.selectedItemIDs == [order[2]])
531        #expect(state.anchorItemID == order[2])
532        #expect(state.cursorItemID == order[2])
533        #expect(state.inactiveSelections.isEmpty)
534        #expect(!state.hasMultipleSelection)
535    }
536}