listless

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

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