← All repositories

listless

A simple list app for Apple platforms

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


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

MListless.xcodeproj/project.pbxproj | 24++++++++++++++++++++++++
MListless/Extensions/ItemListView+Logic.swift | 5++---
MListless/Helpers/ItemListTypes.swift | 23+++++++++++++++++++++++
MListlessiOS/Extensions/ItemListView+Undo.swift | 2++
ATests/Unit/AccentColorTests.swift | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Unit/FocusStateDataTests.swift | 536+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Unit/ItemStoreDeleteAndNormalizeTests.swift | 191+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Unit/ItemValueTests.swift | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

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)
+    }
+}