listless

A simple list app for Apple platforms
Log | Files | Refs | README | LICENSE

FocusStateDataTests.swift (19022B)


      1 import Foundation
      2 import 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
     12 struct 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 }