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 }