commit eb0e9aa4055ea0e307ee1394df69e44016567580
parent 2b430a79bfcbfdad1c5a65da3fd805f1bde8d065
Author: Michael Camilleri <[email protected]>
Date: Thu, 2 Apr 2026 00:48:42 +0900
Improve unit test coverage
This commit adds additional unit tests. One minor bug was discovered
relating to how selection could be impacted by rows being deleted.
Co-Authored-By: Claude 4.6 Opus <[email protected]>
Diffstat:
8 files changed, 1002 insertions(+), 3 deletions(-)
diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj
@@ -12,6 +12,7 @@
072594B24D88AD6D1DF7AFE5 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C611E04943F1D82D6F975592 /* SettingsView.swift */; };
0F12D56A528FCBF8A67864CB /* TappableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ECE0E961F87BA32FA87BF90 /* TappableTextField.swift */; };
0F2E6817C315B947033DA2BE /* DraftRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67CB2A88C7F19F8305EBED43 /* DraftRowView.swift */; };
+ 10F007D4D85F083689F1718B /* FocusStateDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2C23CD3DB0CEA1859F73E37 /* FocusStateDataTests.swift */; };
11AA75BE98CFBE44AEAB7100 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9404C09EE1A4D91DFF338464 /* Media.xcassets */; };
12384AB44B4578E19EF8B0B7 /* ItemStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47892E3231D64596A2A00105 /* ItemStoreTests.swift */; };
12E43D0CD9124037022E3C38 /* KeyCommandBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8B5E429B253183F887C5FD6 /* KeyCommandBridge.swift */; };
@@ -31,12 +32,17 @@
2F5309630692E89276CC3149 /* ItemListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20A09A6C1C2251E96E1B5D96 /* ItemListView.swift */; };
3383645AE13E9C3AAECFBD0B /* ItemListView+PullToClear.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23060125DF03EE84F3AED8CB /* ItemListView+PullToClear.swift */; };
345C3D4D9A82B9AE0C8CB153 /* ItemValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7EB9F584EF43678536F5FDE /* ItemValue.swift */; };
+ 3496092218CA73699428F666 /* ItemStoreDeleteAndNormalizeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5EFAB5629C31FC2C6FFE0A1 /* ItemStoreDeleteAndNormalizeTests.swift */; };
37AEF10712B3325BF9BC72E4 /* BackgroundClickMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F15BF645DEDE8D9E94DB508B /* BackgroundClickMonitor.swift */; };
+ 3C45D0E32A9EADCC874020F7 /* AccentColorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C96B8A403274C4CC7F57460 /* AccentColorTests.swift */; };
+ 3ED20244243BDBEB7140EFDC /* ItemValueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0E02ACB5B5D357B925CAE97 /* ItemValueTests.swift */; };
3EDB6A9A30B4226C15E7F44D /* AppCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46C46FC97E6DB6FF81AC5C22 /* AppCommands.swift */; };
4263E6472020564AA702D117 /* ItemCardModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CF4BADA48F30C5F471EA910 /* ItemCardModifier.swift */; };
+ 42CB50C94EDF48562F680B56 /* ItemStoreDeleteAndNormalizeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5EFAB5629C31FC2C6FFE0A1 /* ItemStoreDeleteAndNormalizeTests.swift */; };
47D9272442A5F15B324D3DAC /* ItemStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B8EC97702E7B218A03B7898 /* ItemStore.swift */; };
4A0682E85B50DF42ECF83B48 /* Listless watchOS.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = C6812E535A24C599C28F9278 /* Listless watchOS.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
4DD2030E321567BD25661760 /* SyncDiagnosticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9288507CE6023425D1DE724 /* SyncDiagnosticsView.swift */; };
+ 4F150E893D1F78BC23A49659 /* AccentColorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C96B8A403274C4CC7F57460 /* AccentColorTests.swift */; };
53700EA974FE4AD771FE89EC /* CloudKitSyncMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E3E82F6093EEFC94A41FED9 /* CloudKitSyncMonitor.swift */; };
543C8A0C8A9E2F77B2C0060F /* AccentColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17DD7EDA74DAAFA27C84CA08 /* AccentColor.swift */; };
568635BB34CD7EBE24E66A15 /* SyncDiagnosticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF3374CE58E7D9378C6997D2 /* SyncDiagnosticsView.swift */; };
@@ -57,6 +63,7 @@
785721EB774EAC6BBA26C038 /* PullToClear.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567DBAC2A39FA2760D006AAB /* PullToClear.swift */; };
790843E40F28B4E186F88F16 /* ItemStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B8EC97702E7B218A03B7898 /* ItemStore.swift */; };
7BB45276D4EB96B8425D2EBD /* AppColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4DB90B3400C191460F4F4BD /* AppColors.swift */; };
+ 7FAD5D37FD7C754BF43B62E1 /* FocusStateDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2C23CD3DB0CEA1859F73E37 /* FocusStateDataTests.swift */; };
84B31DA3CF21D57742C9217A /* ItemStoreEdgeCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52FCE9FC4EFD26C9016389B7 /* ItemStoreEdgeCaseTests.swift */; };
851F46417FE40D6BC765BC70 /* ItemListView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82F2C024B6C1F2F1FD7A25B0 /* ItemListView+Logic.swift */; };
882695E7AE463C0F39ACFF3C /* HoverCursorModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F5D8B5866362D422A2A331C /* HoverCursorModifier.swift */; };
@@ -85,6 +92,7 @@
C89D4C17F91AF91F18B6EF4E /* ItemStoreCompletionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 114A89FD89C8EFB5771B7242 /* ItemStoreCompletionTests.swift */; };
CA439FD953EA59A9664E0D74 /* CloudKitErrorClassifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C3B9FF63645B35E09CF1B1 /* CloudKitErrorClassifierTests.swift */; };
CAB42FAA253E2B347AB0594B /* CloudKitErrorClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A677CC11ACE0BF743AFCE5 /* CloudKitErrorClassifier.swift */; };
+ D000BE93D90883262E43B3CD /* ItemValueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0E02ACB5B5D357B925CAE97 /* ItemValueTests.swift */; };
D24133A6C0105FE8E4528EF2 /* ItemStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B8EC97702E7B218A03B7898 /* ItemStore.swift */; };
D9325B2D23FF2CD644A1A7E3 /* ItemRowMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = E71FBC3E19D32F527B5FE9E6 /* ItemRowMetrics.swift */; };
DAEA21531CFEA94D335FFC6E /* ItemStoreOrderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D1DF1C029FA3C6B12A58E7F /* ItemStoreOrderingTests.swift */; };
@@ -181,6 +189,7 @@
3116A37F1353BF6E18308DD2 /* ItemListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemListView.swift; sourceTree = "<group>"; };
3313FEDB101EECA4B344EEF4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
37F80F7BB632A04A687890F0 /* ItemListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemListView.swift; sourceTree = "<group>"; };
+ 3C96B8A403274C4CC7F57460 /* AccentColorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccentColorTests.swift; sourceTree = "<group>"; };
3DD31E8962F7EEC22EFC0CA9 /* Credits.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = Credits.html; sourceTree = "<group>"; };
4669414A460FC0758D5B49A8 /* KeyboardNavigationModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardNavigationModifier.swift; sourceTree = "<group>"; };
466F9B0E407DF1F5B4789531 /* PlatformScrollIndicatorsModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformScrollIndicatorsModifier.swift; sourceTree = "<group>"; };
@@ -217,6 +226,7 @@
944BAE054AAC1B9C4FC954F9 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
9F5D8B5866362D422A2A331C /* HoverCursorModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoverCursorModifier.swift; sourceTree = "<group>"; };
9FBEB58DD41817F09B0EB9F0 /* Listless.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Listless.xcdatamodel; sourceTree = "<group>"; };
+ A0E02ACB5B5D357B925CAE97 /* ItemValueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemValueTests.swift; sourceTree = "<group>"; };
AC245331D715EA85887C0BA0 /* ListlessiOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListlessiOSApp.swift; sourceTree = "<group>"; };
B7588879D0FA1C2A8BCEF14F /* HoverCursorModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoverCursorModifier.swift; sourceTree = "<group>"; };
B88DC6E36FA41DCB6CEB9647 /* Listless macOS Unit Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Listless macOS Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -225,6 +235,7 @@
BFF7D84B54AE70036D205CA4 /* PullToCreate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PullToCreate.swift; sourceTree = "<group>"; };
C0DF205300C6B51A53B256D6 /* ItemRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemRowView.swift; sourceTree = "<group>"; };
C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
+ C2C23CD3DB0CEA1859F73E37 /* FocusStateDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusStateDataTests.swift; sourceTree = "<group>"; };
C4DB90B3400C191460F4F4BD /* AppColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppColors.swift; sourceTree = "<group>"; };
C611E04943F1D82D6F975592 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
C6812E535A24C599C28F9278 /* Listless watchOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Listless watchOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -241,6 +252,7 @@
DCCE85438FE23E43095B2C25 /* ItemRowSwipeGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemRowSwipeGesture.swift; sourceTree = "<group>"; };
E209A0014EE95F2BC300CE42 /* ItemListView+SyncUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ItemListView+SyncUI.swift"; sourceTree = "<group>"; };
E2174C43654733E9D4023157 /* PlatformTextFieldWidthModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformTextFieldWidthModifier.swift; sourceTree = "<group>"; };
+ E5EFAB5629C31FC2C6FFE0A1 /* ItemStoreDeleteAndNormalizeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemStoreDeleteAndNormalizeTests.swift; sourceTree = "<group>"; };
E71FBC3E19D32F527B5FE9E6 /* ItemRowMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemRowMetrics.swift; sourceTree = "<group>"; };
E8448C5778F75F52719114AF /* PlatformTextFieldWidthModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformTextFieldWidthModifier.swift; sourceTree = "<group>"; };
E85BFCBD4DCB35CC1C8F9401 /* ItemListView+PullGestures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ItemListView+PullGestures.swift"; sourceTree = "<group>"; };
@@ -494,11 +506,15 @@
isa = PBXGroup;
children = (
C9B14DC786A336008AAB78EE /* .gitkeep */,
+ 3C96B8A403274C4CC7F57460 /* AccentColorTests.swift */,
51C3B9FF63645B35E09CF1B1 /* CloudKitErrorClassifierTests.swift */,
+ C2C23CD3DB0CEA1859F73E37 /* FocusStateDataTests.swift */,
114A89FD89C8EFB5771B7242 /* ItemStoreCompletionTests.swift */,
+ E5EFAB5629C31FC2C6FFE0A1 /* ItemStoreDeleteAndNormalizeTests.swift */,
52FCE9FC4EFD26C9016389B7 /* ItemStoreEdgeCaseTests.swift */,
6D1DF1C029FA3C6B12A58E7F /* ItemStoreOrderingTests.swift */,
47892E3231D64596A2A00105 /* ItemStoreTests.swift */,
+ A0E02ACB5B5D357B925CAE97 /* ItemValueTests.swift */,
);
name = Unit;
path = Tests/Unit;
@@ -742,11 +758,15 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ 4F150E893D1F78BC23A49659 /* AccentColorTests.swift in Sources */,
A10BF9D0C850105E4FA1A2AD /* CloudKitErrorClassifierTests.swift in Sources */,
+ 10F007D4D85F083689F1718B /* FocusStateDataTests.swift in Sources */,
066E095075895DE73F217816 /* ItemStoreCompletionTests.swift in Sources */,
+ 42CB50C94EDF48562F680B56 /* ItemStoreDeleteAndNormalizeTests.swift in Sources */,
84B31DA3CF21D57742C9217A /* ItemStoreEdgeCaseTests.swift in Sources */,
F2845953BA77F171FDA2A59F /* ItemStoreOrderingTests.swift in Sources */,
12384AB44B4578E19EF8B0B7 /* ItemStoreTests.swift in Sources */,
+ D000BE93D90883262E43B3CD /* ItemValueTests.swift in Sources */,
E12C1304464FC7799856B2BA /* TestHelpers.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -875,11 +895,15 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ 3C45D0E32A9EADCC874020F7 /* AccentColorTests.swift in Sources */,
CA439FD953EA59A9664E0D74 /* CloudKitErrorClassifierTests.swift in Sources */,
+ 7FAD5D37FD7C754BF43B62E1 /* FocusStateDataTests.swift in Sources */,
C89D4C17F91AF91F18B6EF4E /* ItemStoreCompletionTests.swift in Sources */,
+ 3496092218CA73699428F666 /* ItemStoreDeleteAndNormalizeTests.swift in Sources */,
620D9398218A88B7E4C2331C /* ItemStoreEdgeCaseTests.swift in Sources */,
DAEA21531CFEA94D335FFC6E /* ItemStoreOrderingTests.swift in Sources */,
1E31935122C5E97907B30C70 /* ItemStoreTests.swift in Sources */,
+ 3ED20244243BDBEB7140EFDC /* ItemValueTests.swift in Sources */,
1AA328A921EF8A7FDD03119A /* TestHelpers.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
diff --git a/Listless/Extensions/ItemListView+Logic.swift b/Listless/Extensions/ItemListView+Logic.swift
@@ -294,9 +294,7 @@ extension ItemListViewProtocol {
func deleteItem(itemID: UUID) {
do {
try store.delete(itemID: itemID)
- if fState.selectedItemID == itemID {
- fState.selectedItemID = nil
- }
+ fState.pruneDeletedItems(displayOrder: allItemsInDisplayOrder.map(\.id))
} catch {
presentStoreError(error)
}
@@ -310,6 +308,7 @@ extension ItemListViewProtocol {
presentStoreError(error)
}
}
+ fState.pruneDeletedItems(displayOrder: allItemsInDisplayOrder.map(\.id))
}
// MARK: - Keyboard Navigation
diff --git a/Listless/Helpers/ItemListTypes.swift b/Listless/Helpers/ItemListTypes.swift
@@ -131,6 +131,29 @@ struct FocusStateData {
mergeAdjacentInactiveSelections(displayOrder: displayOrder)
}
+ /// Remove IDs from selection state that are no longer in display order.
+ /// Call after deleting items to prevent ghost selections.
+ mutating func pruneDeletedItems(displayOrder: [UUID]) {
+ let valid = Set(displayOrder)
+ selectedItemIDs.formIntersection(valid)
+ inactiveSelections.formIntersection(valid)
+ if let id = anchorItemID, !valid.contains(id) {
+ anchorItemID = nil
+ }
+ if let id = cursorItemID, !valid.contains(id) {
+ cursorItemID = nil
+ }
+ // If the cursor was pruned, fall back to anchor or first selected.
+ if cursorItemID == nil, !selectedItemIDs.isEmpty {
+ cursorItemID = anchorItemID ?? selectedItemIDs.first
+ }
+ if selectedItemIDs.isEmpty {
+ anchorItemID = nil
+ cursorItemID = nil
+ inactiveSelections = []
+ }
+ }
+
// MARK: - Private Helpers
/// Partition `selectedItemIDs` into those inside vs outside the
diff --git a/ListlessiOS/Extensions/ItemListView+Undo.swift b/ListlessiOS/Extensions/ItemListView+Undo.swift
@@ -34,6 +34,7 @@ extension ItemListView {
return
}
managedObjectContext.undoManager?.endUndoGrouping()
+ fState.pruneDeletedItems(displayOrder: allItemsInDisplayOrder.map(\.id))
let noun = count == 1 ? "item" : "items"
showUndoToast(message: "\(count) \(noun) deleted")
}
@@ -51,6 +52,7 @@ extension ItemListView {
return
}
managedObjectContext.undoManager?.endUndoGrouping()
+ fState.pruneDeletedItems(displayOrder: allItemsInDisplayOrder.map(\.id))
let noun = count == 1 ? "item" : "items"
showUndoToast(message: "\(count) \(noun) cleared")
}
diff --git a/Tests/Unit/AccentColorTests.swift b/Tests/Unit/AccentColorTests.swift
@@ -0,0 +1,152 @@
+import Foundation
+import SwiftUI
+import Testing
+
+#if os(macOS)
+@testable import Listless
+#else
+@testable import Listless_iOS
+#endif
+
+@Suite("AccentColor")
+struct AccentColorTests {
+
+ // MARK: - ColorTheme properties
+
+ @Test("displayOrder sorts themes alphabetically by displayName")
+ func displayOrderSorting() {
+ let order = ColorTheme.displayOrder
+ let names = order.map(\.displayName)
+
+ #expect(names == names.sorted())
+ }
+
+ @Test("All themes have unique display names")
+ func uniqueDisplayNames() {
+ let names = ColorTheme.allCases.map(\.displayName)
+
+ #expect(Set(names).count == names.count)
+ }
+
+ @Test("displayOrder contains all cases")
+ func displayOrderContainsAll() {
+ #expect(Set(ColorTheme.displayOrder) == Set(ColorTheme.allCases))
+ }
+
+ // MARK: - itemColor
+
+ @Test(
+ "Single item returns top color for all themes",
+ arguments: ColorTheme.allCases
+ )
+ func singleItemReturnsTopColor(theme: ColorTheme) {
+ let singleColor = itemColor(forIndex: 0, total: 1, theme: theme)
+ // For a single item, itemColor returns the top HSB directly.
+ // We can't inspect Color internals, but we can verify it doesn't crash
+ // and returns a non-nil Color.
+ #expect(type(of: singleColor) == Color.self)
+ }
+
+ @Test("First item of many matches top color")
+ func firstItemMatchesTop() {
+ let topColor = itemColor(forIndex: 0, total: 1, theme: .pilbara)
+ let firstOfMany = itemColor(forIndex: 0, total: 10, theme: .pilbara)
+
+ // Both should be the top color since progress=0 yields top for both paths.
+ #expect(topColor.description == firstOfMany.description)
+ }
+
+ @Test("Last item of many uses bottom color")
+ func lastItemUsesBottom() {
+ // progress = 1.0 → interpolates from mid to bottom with progress=1.0 → bottom
+ let lastColor = itemColor(forIndex: 9, total: 10, theme: .pilbara)
+ #expect(type(of: lastColor) == Color.self)
+ }
+
+ @Test("Middle item of odd count uses mid color")
+ func middleItemUsesMidColor() {
+ // For total=3, index=1 → progress = 0.5 → exactly at mid
+ let midColor = itemColor(forIndex: 1, total: 3, theme: .pilbara)
+ #expect(type(of: midColor) == Color.self)
+ }
+
+ @Test(
+ "Gradient produces distinct colors for each position",
+ arguments: ColorTheme.allCases
+ )
+ func gradientProducesDistinctColors(theme: ColorTheme) {
+ let total = 5
+ let colors = (0..<total).map {
+ itemColor(forIndex: $0, total: total, theme: theme).description
+ }
+ // Adjacent colors should differ (gradient is continuous but not flat).
+ for i in 0..<(colors.count - 1) {
+ #expect(colors[i] != colors[i + 1])
+ }
+ }
+
+ @Test("Different themes produce different colors for same position")
+ func differentThemesDiffer() {
+ let pilbaraColor = itemColor(forIndex: 0, total: 5, theme: .pilbara).description
+ let collaroyColor = itemColor(forIndex: 0, total: 5, theme: .collaroy).description
+
+ #expect(pilbaraColor != collaroyColor)
+ }
+
+ // MARK: - Edge cases / out-of-range inputs
+
+ @Test("total=0 returns top color without crashing")
+ func totalZero() {
+ // total <= 1 hits the guard, so total=0 should behave like total=1.
+ let color = itemColor(forIndex: 0, total: 0, theme: .pilbara)
+ let topColor = itemColor(forIndex: 0, total: 1, theme: .pilbara)
+ #expect(color.description == topColor.description)
+ }
+
+ @Test("Negative total returns top color without crashing")
+ func negativeTotalDoesNotCrash() {
+ let color = itemColor(forIndex: 0, total: -1, theme: .pilbara)
+ let topColor = itemColor(forIndex: 0, total: 1, theme: .pilbara)
+ #expect(color.description == topColor.description)
+ }
+
+ @Test("Negative index produces a color without crashing")
+ func negativeIndex() {
+ // Extrapolates beyond the gradient — should not crash.
+ let color = itemColor(forIndex: -1, total: 5, theme: .pilbara)
+ #expect(type(of: color) == Color.self)
+ }
+
+ @Test("Index beyond total produces a color without crashing")
+ func indexBeyondTotal() {
+ // Extrapolates beyond the gradient — should not crash.
+ let color = itemColor(forIndex: 10, total: 5, theme: .pilbara)
+ #expect(type(of: color) == Color.self)
+ }
+
+ @Test("Very large total does not crash")
+ func veryLargeTotal() {
+ let color = itemColor(forIndex: 500, total: 1000, theme: .collaroy)
+ #expect(type(of: color) == Color.self)
+ }
+
+ // MARK: - cachedItemColor
+
+ @Test("Cached color returns same result as uncached")
+ @MainActor
+ func cachedMatchesUncached() {
+ let uncached = itemColor(forIndex: 2, total: 5, theme: .pilbara).description
+ let cached = cachedItemColor(forIndex: 2, total: 5, theme: .pilbara).description
+
+ #expect(cached == uncached)
+ }
+
+ @Test("Repeated cached calls return consistent results")
+ @MainActor
+ func cachedConsistency() {
+ let first = cachedItemColor(forIndex: 1, total: 4, theme: .collaroy).description
+ let second = cachedItemColor(forIndex: 1, total: 4, theme: .collaroy).description
+
+ #expect(first == second)
+ }
+}
diff --git a/Tests/Unit/FocusStateDataTests.swift b/Tests/Unit/FocusStateDataTests.swift
@@ -0,0 +1,536 @@
+import Foundation
+import Testing
+
+#if os(macOS)
+@testable import Listless
+#else
+@testable import Listless_iOS
+#endif
+
+@Suite("FocusStateData Selection", .serialized)
+@MainActor
+struct FocusStateDataSelectionTests {
+
+ // Five stable IDs used across tests, representing display order.
+ static let ids = (0..<5).map { _ in UUID() }
+ static var displayOrder: [UUID] { ids }
+
+ // MARK: - selectedItemID (single-select setter/getter)
+
+ @Test("selectedItemID setter establishes single selection")
+ func selectedItemIDSetter() {
+ var state = FocusStateData()
+ let id = Self.ids[2]
+
+ state.selectedItemID = id
+
+ #expect(state.selectedItemID == id)
+ #expect(state.cursorItemID == id)
+ #expect(state.anchorItemID == id)
+ #expect(state.selectedItemIDs == [id])
+ #expect(state.inactiveSelections.isEmpty)
+ }
+
+ @Test("selectedItemID setter clears selection when set to nil")
+ func selectedItemIDSetterNil() {
+ var state = FocusStateData()
+ state.selectedItemID = Self.ids[0]
+
+ state.selectedItemID = nil
+
+ #expect(state.selectedItemID == nil)
+ #expect(state.cursorItemID == nil)
+ #expect(state.anchorItemID == nil)
+ #expect(state.selectedItemIDs.isEmpty)
+ #expect(state.inactiveSelections.isEmpty)
+ }
+
+ // MARK: - isItemSelected / hasMultipleSelection
+
+ @Test("isItemSelected returns true only for selected items")
+ func isItemSelected() {
+ var state = FocusStateData()
+ state.selectedItemID = Self.ids[1]
+
+ #expect(state.isItemSelected(Self.ids[1]))
+ #expect(!state.isItemSelected(Self.ids[0]))
+ }
+
+ @Test("hasMultipleSelection is false for single selection")
+ func hasMultipleSelectionSingle() {
+ var state = FocusStateData()
+ state.selectedItemID = Self.ids[0]
+
+ #expect(!state.hasMultipleSelection)
+ }
+
+ // MARK: - selectAll
+
+ @Test("selectAll selects every item in display order")
+ func selectAllBasic() {
+ var state = FocusStateData()
+ let order = Self.displayOrder
+
+ state.selectAll(displayOrder: order)
+
+ #expect(state.selectedItemIDs == Set(order))
+ #expect(state.anchorItemID == order.first)
+ #expect(state.cursorItemID == order.last)
+ #expect(state.inactiveSelections.isEmpty)
+ #expect(state.hasMultipleSelection)
+ }
+
+ @Test("selectAll with empty display order does nothing")
+ func selectAllEmpty() {
+ var state = FocusStateData()
+ state.selectedItemID = Self.ids[0]
+
+ state.selectAll(displayOrder: [])
+
+ #expect(state.selectedItemID == Self.ids[0])
+ }
+
+ @Test("selectAll with single item sets anchor and cursor to same item")
+ func selectAllSingleItem() {
+ var state = FocusStateData()
+ let single = [Self.ids[0]]
+
+ state.selectAll(displayOrder: single)
+
+ #expect(state.anchorItemID == Self.ids[0])
+ #expect(state.cursorItemID == Self.ids[0])
+ #expect(state.selectedItemIDs == Set(single))
+ #expect(!state.hasMultipleSelection)
+ }
+
+ @Test("selectAll clears inactive selections from prior Cmd+Click")
+ func selectAllClearsInactive() {
+ var state = FocusStateData()
+ let order = Self.displayOrder
+ // Build up inactive selections via toggleSelection.
+ state.toggleSelection(itemID: order[0], displayOrder: order)
+ state.toggleSelection(itemID: order[3], displayOrder: order)
+
+ state.selectAll(displayOrder: order)
+
+ #expect(state.inactiveSelections.isEmpty)
+ #expect(state.selectedItemIDs == Set(order))
+ }
+
+ // MARK: - toggleSelection (Cmd+Click)
+
+ @Test("Toggle adds an unselected item to the selection")
+ func toggleAddsItem() {
+ var state = FocusStateData()
+ let order = Self.displayOrder
+
+ state.toggleSelection(itemID: order[1], displayOrder: order)
+
+ #expect(state.isItemSelected(order[1]))
+ #expect(state.selectedItemIDs.count == 1)
+ }
+
+ @Test("Toggle removes a selected item from the selection")
+ func toggleRemovesItem() {
+ var state = FocusStateData()
+ let order = Self.displayOrder
+ state.toggleSelection(itemID: order[1], displayOrder: order)
+
+ state.toggleSelection(itemID: order[1], displayOrder: order)
+
+ #expect(!state.isItemSelected(order[1]))
+ #expect(state.selectedItemIDs.isEmpty)
+ }
+
+ @Test("Toggle sets anchor to item below toggled item")
+ func toggleAnchorBelowToggledItem() {
+ var state = FocusStateData()
+ let order = Self.displayOrder
+
+ state.toggleSelection(itemID: order[1], displayOrder: order)
+
+ // Item below index 1 is index 2.
+ #expect(state.anchorItemID == order[2])
+ }
+
+ @Test("Toggle at bottom of list sets anchor to self")
+ func toggleAnchorAtBottom() {
+ var state = FocusStateData()
+ let order = Self.displayOrder
+
+ state.toggleSelection(itemID: order[4], displayOrder: order)
+
+ #expect(state.anchorItemID == order[4])
+ }
+
+ @Test("Adding via toggle resets cursor to anchor")
+ func toggleAddResetsCursor() {
+ var state = FocusStateData()
+ let order = Self.displayOrder
+ state.toggleSelection(itemID: order[0], displayOrder: order)
+
+ state.toggleSelection(itemID: order[3], displayOrder: order)
+
+ // Toggling index 3 sets anchor to index 4; adding resets cursor to anchor.
+ #expect(state.cursorItemID == order[4])
+ }
+
+ @Test("Deselecting via toggle keeps cursor at its previous position")
+ func toggleDeselectKeepsCursor() {
+ var state = FocusStateData()
+ let order = Self.displayOrder
+ // Select two items.
+ state.toggleSelection(itemID: order[1], displayOrder: order)
+ state.toggleSelection(itemID: order[3], displayOrder: order)
+ let cursorBefore = state.cursorItemID
+
+ // Deselect one of them.
+ state.toggleSelection(itemID: order[1], displayOrder: order)
+
+ #expect(state.cursorItemID == cursorBefore)
+ }
+
+ @Test("Deselecting the last selected item clears all state")
+ func toggleDeselectLast() {
+ var state = FocusStateData()
+ let order = Self.displayOrder
+ state.toggleSelection(itemID: order[2], displayOrder: order)
+
+ state.toggleSelection(itemID: order[2], displayOrder: order)
+
+ #expect(state.selectedItemIDs.isEmpty)
+ #expect(state.anchorItemID == nil)
+ #expect(state.cursorItemID == nil)
+ #expect(state.inactiveSelections.isEmpty)
+ }
+
+ @Test("Toggle with ID not in display order is ignored")
+ func toggleUnknownID() {
+ var state = FocusStateData()
+ let unknown = UUID()
+
+ state.toggleSelection(itemID: unknown, displayOrder: Self.displayOrder)
+
+ #expect(state.selectedItemIDs.isEmpty)
+ }
+
+ @Test("Multiple toggles build discontinuous selection")
+ func toggleMultipleDiscontinuous() {
+ var state = FocusStateData()
+ let order = Self.displayOrder
+
+ state.toggleSelection(itemID: order[0], displayOrder: order)
+ state.toggleSelection(itemID: order[2], displayOrder: order)
+ state.toggleSelection(itemID: order[4], displayOrder: order)
+
+ #expect(state.isItemSelected(order[0]))
+ #expect(state.isItemSelected(order[2]))
+ #expect(state.isItemSelected(order[4]))
+ #expect(!state.isItemSelected(order[1]))
+ #expect(!state.isItemSelected(order[3]))
+ #expect(state.hasMultipleSelection)
+ }
+
+ // MARK: - extendSelection (Shift+Arrow)
+
+ @Test("Extend selection downward from anchor")
+ func extendDown() {
+ var state = FocusStateData()
+ let order = Self.displayOrder
+ state.selectedItemID = order[1]
+
+ state.extendSelection(to: order[3], displayOrder: order)
+
+ #expect(state.selectedItemIDs == Set(order[1...3]))
+ #expect(state.cursorItemID == order[3])
+ #expect(state.anchorItemID == order[1])
+ }
+
+ @Test("Extend selection upward from anchor")
+ func extendUp() {
+ var state = FocusStateData()
+ let order = Self.displayOrder
+ state.selectedItemID = order[3]
+
+ state.extendSelection(to: order[1], displayOrder: order)
+
+ #expect(state.selectedItemIDs == Set(order[1...3]))
+ #expect(state.cursorItemID == order[1])
+ #expect(state.anchorItemID == order[3])
+ }
+
+ @Test("Extend selection contracts when cursor moves back toward anchor")
+ func extendContract() {
+ var state = FocusStateData()
+ let order = Self.displayOrder
+ state.selectedItemID = order[1]
+
+ state.extendSelection(to: order[4], displayOrder: order)
+ state.extendSelection(to: order[2], displayOrder: order)
+
+ #expect(state.selectedItemIDs == Set(order[1...2]))
+ #expect(state.cursorItemID == order[2])
+ }
+
+ @Test("Extend to same position as anchor selects only anchor")
+ func extendToAnchor() {
+ var state = FocusStateData()
+ let order = Self.displayOrder
+ state.selectedItemID = order[2]
+
+ state.extendSelection(to: order[2], displayOrder: order)
+
+ #expect(state.selectedItemIDs == [order[2]])
+ #expect(state.cursorItemID == order[2])
+ }
+
+ @Test("Extend without anchor is ignored")
+ func extendWithoutAnchor() {
+ var state = FocusStateData()
+
+ state.extendSelection(to: Self.ids[2], displayOrder: Self.displayOrder)
+
+ #expect(state.selectedItemIDs.isEmpty)
+ }
+
+ @Test("Extend with unknown target ID is ignored")
+ func extendUnknownTarget() {
+ var state = FocusStateData()
+ state.selectedItemID = Self.ids[0]
+
+ state.extendSelection(to: UUID(), displayOrder: Self.displayOrder)
+
+ #expect(state.selectedItemIDs == [Self.ids[0]])
+ }
+
+ // MARK: - Cmd+Click then Shift+Arrow (inactive selection preservation)
+
+ @Test("Shift+Arrow after Cmd+Click preserves inactive selections")
+ func extendPreservesInactive() {
+ var state = FocusStateData()
+ let order = Self.displayOrder
+
+ // Cmd+Click items 0 and 4 to create a discontinuous selection.
+ state.toggleSelection(itemID: order[0], displayOrder: order)
+ state.toggleSelection(itemID: order[4], displayOrder: order)
+
+ // After toggling index 4, anchor is set to index 4 (last item).
+ // Shift+Arrow down to index 4 should preserve index 0 as inactive.
+ state.extendSelection(to: order[4], displayOrder: order)
+
+ #expect(state.isItemSelected(order[0]))
+ #expect(state.isItemSelected(order[4]))
+ }
+
+ @Test("Adjacent inactive selections merge into active range")
+ func mergeAdjacentInactive() {
+ var state = FocusStateData()
+ let order = Self.displayOrder
+
+ // Select items 0 and 2 via Cmd+Click.
+ state.toggleSelection(itemID: order[0], displayOrder: order)
+ state.toggleSelection(itemID: order[2], displayOrder: order)
+
+ // After toggling 0 then 2:
+ // - toggleSelection(0): selected={0}, anchor=1, cursor=1
+ // - toggleSelection(2): selected={0,2}, anchor=3, cursor=3
+ // inactive = {0} (outside anchor(3)–cursor(3) range)
+
+ // Extend from anchor (3) up to 1 — active range is [1,2,3].
+ // Item 0 is adjacent to active range at index 1, so it merges.
+ state.extendSelection(to: order[1], displayOrder: order)
+
+ #expect(state.isItemSelected(order[0]))
+ #expect(state.isItemSelected(order[1]))
+ #expect(state.isItemSelected(order[2]))
+ #expect(state.isItemSelected(order[3]))
+ #expect(state.inactiveSelections.isEmpty)
+ }
+
+ @Test("Non-adjacent inactive selections stay inactive")
+ func nonAdjacentStaysInactive() {
+ var state = FocusStateData()
+ let order = Self.displayOrder
+
+ // Select items 0 and 4 via Cmd+Click.
+ state.toggleSelection(itemID: order[0], displayOrder: order)
+ state.toggleSelection(itemID: order[4], displayOrder: order)
+
+ // After toggling 0 then 4:
+ // - toggleSelection(0): selected={0}, anchor=1, cursor=1
+ // - toggleSelection(4): selected={0,4}, anchor=4, cursor=4
+ // inactive = {0} (outside anchor(4)–cursor(4) range)
+
+ // Extend from anchor (4) to 3 — active range is [3,4].
+ // Item 0 is not adjacent to index 3, so stays inactive.
+ state.extendSelection(to: order[3], displayOrder: order)
+
+ #expect(state.isItemSelected(order[0]))
+ #expect(state.isItemSelected(order[3]))
+ #expect(state.isItemSelected(order[4]))
+ #expect(!state.isItemSelected(order[1]))
+ #expect(!state.isItemSelected(order[2]))
+ #expect(state.inactiveSelections.contains(order[0]))
+ }
+
+ // MARK: - Display order changes between operations
+
+ @Test("Prune then extend after item is removed from display order")
+ func pruneAndExtendAfterItemRemoved() {
+ var state = FocusStateData()
+ let order = Self.displayOrder
+
+ // Cmd+Click items 1 and 3.
+ state.toggleSelection(itemID: order[1], displayOrder: order)
+ state.toggleSelection(itemID: order[3], displayOrder: order)
+ // State: selected={1,3}, anchor=4, cursor=4, inactive={1,3}
+
+ // Simulate item 3 being deleted — prune stale IDs.
+ let reducedOrder = [order[0], order[1], order[2], order[4]]
+ state.pruneDeletedItems(displayOrder: reducedOrder)
+
+ // order[3] removed from selected and inactive; anchor/cursor (order[4]) still valid.
+ #expect(!state.isItemSelected(order[3]))
+ #expect(state.isItemSelected(order[1]))
+ #expect(state.anchorItemID == order[4])
+
+ // Extend from anchor (order[4]) toward order[2].
+ state.extendSelection(to: order[2], displayOrder: reducedOrder)
+
+ // Active range [order[2], order[4]]; order[1] is adjacent inactive → merges.
+ #expect(state.isItemSelected(order[1]))
+ #expect(state.isItemSelected(order[2]))
+ #expect(state.isItemSelected(order[4]))
+ #expect(!state.isItemSelected(order[3]))
+ #expect(state.inactiveSelections.isEmpty)
+ }
+
+ @Test("Prune then extend when anchor was the deleted item")
+ func pruneRemovedAnchorThenExtend() {
+ var state = FocusStateData()
+ let order = Self.displayOrder
+ state.selectedItemID = order[2]
+
+ // Remove the anchor/cursor item from display order.
+ let reducedOrder = [order[0], order[1], order[3], order[4]]
+ state.pruneDeletedItems(displayOrder: reducedOrder)
+
+ // Anchor and cursor pruned; selection is now empty.
+ #expect(state.selectedItemIDs.isEmpty)
+ #expect(state.anchorItemID == nil)
+ #expect(state.cursorItemID == nil)
+ }
+
+ @Test("Toggle after display order is reordered uses new positions")
+ func toggleWithReorderedDisplay() {
+ var state = FocusStateData()
+ let order = Self.displayOrder
+
+ // Reverse the display order (simulating a sort change).
+ let reversed = Array(order.reversed())
+
+ // Toggle item that was at index 4 in original, now at index 0.
+ state.toggleSelection(itemID: order[4], displayOrder: reversed)
+
+ // In reversed order, order[4] is at index 0, so "below" is index 1 = order[3].
+ #expect(state.anchorItemID == order[3])
+ #expect(state.isItemSelected(order[4]))
+ }
+
+ @Test("Prune after selectAll with deleted items cleans up selection")
+ func pruneAfterSelectAll() {
+ var state = FocusStateData()
+ let order = Self.displayOrder
+ state.selectAll(displayOrder: order)
+
+ // Items 0 and 2 deleted.
+ let shrunken = [order[1], order[3], order[4]]
+ state.pruneDeletedItems(displayOrder: shrunken)
+
+ #expect(state.selectedItemIDs == Set([order[1], order[3], order[4]]))
+ // Anchor (order[0]) was pruned; cursor (order[4]) survives.
+ #expect(state.anchorItemID == nil)
+ #expect(state.cursorItemID == order[4])
+ }
+
+ // MARK: - pruneDeletedItems
+
+ @Test("Prune with no deletions changes nothing")
+ func pruneNoOp() {
+ var state = FocusStateData()
+ let order = Self.displayOrder
+ state.toggleSelection(itemID: order[1], displayOrder: order)
+ state.toggleSelection(itemID: order[3], displayOrder: order)
+ let selectedBefore = state.selectedItemIDs
+ let inactiveBefore = state.inactiveSelections
+
+ state.pruneDeletedItems(displayOrder: order)
+
+ #expect(state.selectedItemIDs == selectedBefore)
+ #expect(state.inactiveSelections == inactiveBefore)
+ }
+
+ @Test("Prune removes ghost IDs from selectedItemIDs and inactiveSelections")
+ func pruneRemovesGhosts() {
+ var state = FocusStateData()
+ let order = Self.displayOrder
+ // Cmd+Click 0 and 2 to create inactive selection at 0.
+ state.toggleSelection(itemID: order[0], displayOrder: order)
+ state.toggleSelection(itemID: order[2], displayOrder: order)
+
+ // Delete item 0.
+ let reducedOrder = [order[1], order[2], order[3], order[4]]
+ state.pruneDeletedItems(displayOrder: reducedOrder)
+
+ #expect(!state.isItemSelected(order[0]))
+ #expect(!state.inactiveSelections.contains(order[0]))
+ #expect(state.isItemSelected(order[2]))
+ }
+
+ @Test("Prune with all items deleted clears everything")
+ func pruneAllDeleted() {
+ var state = FocusStateData()
+ let order = Self.displayOrder
+ state.selectAll(displayOrder: order)
+
+ state.pruneDeletedItems(displayOrder: [])
+
+ #expect(state.selectedItemIDs.isEmpty)
+ #expect(state.anchorItemID == nil)
+ #expect(state.cursorItemID == nil)
+ #expect(state.inactiveSelections.isEmpty)
+ }
+
+ @Test("Prune falls back cursor to anchor when cursor is deleted")
+ func pruneCursorFallsBackToAnchor() {
+ var state = FocusStateData()
+ let order = Self.displayOrder
+ state.selectedItemID = order[1]
+ state.extendSelection(to: order[3], displayOrder: order)
+ // anchor=order[1], cursor=order[3]
+
+ // Delete cursor item.
+ let reducedOrder = [order[0], order[1], order[2], order[4]]
+ state.pruneDeletedItems(displayOrder: reducedOrder)
+
+ #expect(state.cursorItemID == order[1])
+ #expect(state.anchorItemID == order[1])
+ }
+
+ // MARK: - selectedItemID setter resets multi-select
+
+ @Test("Setting selectedItemID collapses multi-select to single")
+ func setterResetsMultiSelect() {
+ var state = FocusStateData()
+ let order = Self.displayOrder
+ state.selectAll(displayOrder: order)
+
+ state.selectedItemID = order[2]
+
+ #expect(state.selectedItemIDs == [order[2]])
+ #expect(state.anchorItemID == order[2])
+ #expect(state.cursorItemID == order[2])
+ #expect(state.inactiveSelections.isEmpty)
+ #expect(!state.hasMultipleSelection)
+ }
+}
diff --git a/Tests/Unit/ItemStoreDeleteAndNormalizeTests.swift b/Tests/Unit/ItemStoreDeleteAndNormalizeTests.swift
@@ -0,0 +1,191 @@
+import Foundation
+import Testing
+
+#if os(macOS)
+@testable import Listless
+#else
+@testable import Listless_iOS
+#endif
+
+@Suite("ItemStore Delete Multiple & Normalize", .serialized)
+@MainActor
+struct ItemStoreDeleteAndNormalizeTests {
+
+ // MARK: - deleteMultiple
+
+ @Test("Delete multiple items removes all specified items")
+ func deleteMultipleBasic() async throws {
+ let (store, ids) = try makeTestStoreWithItems(count: 4)
+
+ try store.deleteMultiple(itemIDs: [ids[0], ids[2]])
+
+ let remaining = try store.fetchItems()
+ let remainingIDs = remaining.map(\.id)
+ #expect(remainingIDs.contains(ids[1]))
+ #expect(remainingIDs.contains(ids[3]))
+ #expect(!remainingIDs.contains(ids[0]))
+ #expect(!remainingIDs.contains(ids[2]))
+ #expect(remaining.count == 2)
+ }
+
+ @Test("Delete multiple with empty array does nothing")
+ func deleteMultipleEmpty() async throws {
+ let (store, _) = try makeTestStoreWithItems(count: 3)
+
+ try store.deleteMultiple(itemIDs: [])
+
+ let items = try store.fetchItems()
+ #expect(items.count == 3)
+ }
+
+ @Test("Delete multiple skips unknown IDs without error")
+ func deleteMultipleWithUnknownIDs() async throws {
+ let (store, ids) = try makeTestStoreWithItems(count: 3)
+
+ try store.deleteMultiple(itemIDs: [ids[0], UUID(), ids[2]])
+
+ let remaining = try store.fetchItems()
+ #expect(remaining.count == 1)
+ #expect(remaining[0].id == ids[1])
+ }
+
+ @Test("Delete multiple handles mix of active and completed items")
+ func deleteMultipleMixedState() async throws {
+ let (store, ids) = try makeTestStoreWithItems(count: 4)
+ try store.complete(itemID: ids[1])
+ try store.complete(itemID: ids[3])
+
+ try store.deleteMultiple(itemIDs: [ids[0], ids[1]])
+
+ let remaining = try store.fetchItems()
+ let remainingIDs = Set(remaining.map(\.id))
+ #expect(remainingIDs == Set([ids[2], ids[3]]))
+ }
+
+ @Test("Delete multiple with all items leaves store empty")
+ func deleteMultipleAll() async throws {
+ let (store, ids) = try makeTestStoreWithItems(count: 3)
+
+ try store.deleteMultiple(itemIDs: ids)
+
+ let items = try store.fetchItems()
+ #expect(items.isEmpty)
+ }
+
+ // MARK: - normalizeSortOrders
+
+ @Test("Normalize assigns evenly spaced sort orders")
+ func normalizeBasic() async throws {
+ let (store, ids) = try makeTestStoreWithItems(count: 3)
+ // Move items around to create irregular spacing.
+ try store.moveItem(itemID: ids[2], toIndex: 0)
+
+ try store.normalizeSortOrders()
+
+ let items = try store.fetchItems().filter { !$0.isCompleted }
+ #expect(items.count == 3)
+ for (index, item) in items.enumerated() {
+ #expect(item.sortOrder == Int64(index) * 1000)
+ }
+ }
+
+ @Test("Normalize preserves existing order")
+ func normalizePreservesOrder() async throws {
+ let (store, _) = try makeTestStoreWithItems(
+ count: 3, titles: ["First", "Second", "Third"])
+
+ try store.normalizeSortOrders()
+
+ let items = try store.fetchItems().filter { !$0.isCompleted }
+ #expect(items.map(\.title) == ["First", "Second", "Third"])
+ }
+
+ @Test("Normalize on empty store does nothing")
+ func normalizeEmpty() async throws {
+ let store = makeTestStore()
+
+ try store.normalizeSortOrders()
+
+ let items = try store.fetchItems()
+ #expect(items.isEmpty)
+ }
+
+ @Test("Normalize ignores completed items")
+ func normalizeIgnoresCompleted() async throws {
+ let (store, ids) = try makeTestStoreWithItems(count: 4)
+ try store.complete(itemID: ids[1])
+ try store.complete(itemID: ids[3])
+
+ try store.normalizeSortOrders()
+
+ let active = try store.fetchItems().filter { !$0.isCompleted }
+ #expect(active.count == 2)
+ #expect(active[0].sortOrder == 0)
+ #expect(active[1].sortOrder == 1000)
+
+ // Completed items should still exist.
+ let all = try store.fetchItems()
+ #expect(all.count == 4)
+ }
+
+ @Test("Normalize then create at beginning uses correct offset")
+ func normalizeFollowedByPrepend() async throws {
+ let (store, _) = try makeTestStoreWithItems(count: 3)
+ try store.normalizeSortOrders()
+
+ // After normalize: sort orders are 0, 1000, 2000.
+ // Creating at beginning should use minSortOrder - 1000 = -1000.
+ _ = try store.createItem(title: "Prepended", atBeginning: true)
+ try store.save()
+
+ let items = try store.fetchItems().filter { !$0.isCompleted }
+ #expect(items.first?.title == "Prepended")
+ #expect(items.first?.sortOrder == -1000)
+ }
+
+ @Test("Normalize with single item sets sort order to zero")
+ func normalizeSingleItem() async throws {
+ let store = makeTestStore()
+ _ = try store.createItem(title: "Only", atBeginning: true)
+ try store.save()
+
+ try store.normalizeSortOrders()
+
+ let items = try store.fetchItems().filter { !$0.isCompleted }
+ #expect(items.count == 1)
+ #expect(items[0].sortOrder == 0)
+ }
+
+ @Test("Double normalize is idempotent")
+ func doubleNormalize() async throws {
+ let (store, _) = try makeTestStoreWithItems(count: 4)
+ try store.moveItem(itemID: try store.fetchItems()[3].id, toIndex: 0)
+
+ try store.normalizeSortOrders()
+ let afterFirst = try store.fetchItems().filter { !$0.isCompleted }.map(\.sortOrder)
+
+ try store.normalizeSortOrders()
+ let afterSecond = try store.fetchItems().filter { !$0.isCompleted }.map(\.sortOrder)
+
+ #expect(afterFirst == afterSecond)
+ }
+
+ @Test("Normalize fixes negative sort orders from prepend operations")
+ func normalizeFixesNegativeOrders() async throws {
+ let store = makeTestStore()
+ _ = try store.createItem(title: "Original")
+ try store.save()
+ _ = try store.createItem(title: "Prepended", atBeginning: true)
+ try store.save()
+
+ let beforeNormalize = try store.fetchItems().filter { !$0.isCompleted }
+ #expect(beforeNormalize[0].sortOrder < 0)
+
+ try store.normalizeSortOrders()
+
+ let afterNormalize = try store.fetchItems().filter { !$0.isCompleted }
+ #expect(afterNormalize[0].sortOrder == 0)
+ #expect(afterNormalize[1].sortOrder == 1000)
+ #expect(afterNormalize[0].title == "Prepended")
+ }
+}
diff --git a/Tests/Unit/ItemValueTests.swift b/Tests/Unit/ItemValueTests.swift
@@ -0,0 +1,72 @@
+import Foundation
+import Testing
+
+#if os(macOS)
+@testable import Listless
+#else
+@testable import Listless_iOS
+#endif
+
+@Suite("ItemValue Snapshot", .serialized)
+@MainActor
+struct ItemValueTests {
+
+ @Test("ItemValue copies all fields from active ItemEntity")
+ func snapshotActiveItem() async throws {
+ let store = makeTestStore()
+ let entity = try store.createItem(title: "Buy milk")
+ try store.save()
+
+ let value = ItemValue(entity)
+
+ #expect(value.id == entity.id)
+ #expect(value.title == entity.title)
+ #expect(value.isCompleted == false)
+ #expect(value.sortOrder == entity.sortOrder)
+ #expect(value.completedOrder == 0)
+ }
+
+ @Test("ItemValue copies completed state correctly")
+ func snapshotCompletedItem() async throws {
+ let store = makeTestStore()
+ let entity = try store.createItem(title: "Done task")
+ try store.save()
+ try store.complete(itemID: entity.id)
+
+ let items = try store.fetchItems()
+ let completed = items.first { $0.id == entity.id }!
+ let value = ItemValue(completed)
+
+ #expect(value.isCompleted == true)
+ #expect(value.completedOrder > 0)
+ #expect(value.completedOrder == completed.completedOrder)
+ }
+
+ @Test("ItemValue is independent of entity mutations")
+ func snapshotIndependence() async throws {
+ let store = makeTestStore()
+ let entity = try store.createItem(title: "Original")
+ try store.save()
+
+ let value = ItemValue(entity)
+ try store.update(itemID: entity.id, title: "Changed")
+
+ #expect(value.title == "Original")
+ #expect(entity.title == "Changed")
+ }
+
+ @Test("ItemValue preserves sort order from entity")
+ func snapshotSortOrder() async throws {
+ let (store, ids) = try makeTestStoreWithItems(count: 3)
+ let items = try store.fetchItems()
+
+ let values = items.filter { !$0.isCompleted }.map { ItemValue($0) }
+
+ #expect(values.count == 3)
+ for (i, value) in values.enumerated() {
+ #expect(value.id == ids[i])
+ }
+ #expect(values[0].sortOrder < values[1].sortOrder)
+ #expect(values[1].sortOrder < values[2].sortOrder)
+ }
+}