listless

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

commit 01c629a9b1aa3ff44f7f77e263df79a492d03953
parent 6461f078965680b59f9fd7ffba514062116ddad6
Author: Michael Camilleri <[email protected]>
Date:   Sun,  8 Feb 2026 00:11:43 +0900

Add unit tests

Co-Authored-By: Claude 4.5 Sonnet <[email protected]>

Diffstat:
MListless.xcodeproj/project.pbxproj | 208++++++++++++++++++++++++++++++++++++++++++-------------------------------------
MListless.xcodeproj/xcshareddata/xcschemes/Listless iOS.xcscheme | 14++++++++++++++
MListless.xcodeproj/xcshareddata/xcschemes/Listless macOS.xcscheme | 13-------------
MListless/Models/TaskStore.swift | 24++++++++++++++++--------
MListlessiOS/Views/ClickableTextField.swift | 15++++++++++++---
ATests/Support/TestHelpers.swift | 37+++++++++++++++++++++++++++++++++++++
ATests/Unit/TaskStoreCompletionTests.swift | 148+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Unit/TaskStoreEdgeCaseTests.swift | 163+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Unit/TaskStoreOrderingTests.swift | 186+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Unit/TaskStoreTests.swift | 189+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 876 insertions(+), 121 deletions(-)

diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj @@ -7,12 +7,12 @@ objects = { /* Begin PBXBuildFile section */ - 031B814FDF3F9B20E3602563 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 992AE558C5BBEE16D70B8FF4 /* README.md */; }; 0ACA67F6578EFF181EE5C9A7 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBB8A3BEB346267B30B4675F /* TaskItem.swift */; }; 15B71073767FB4766A6BA2BE /* HoverCursorModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D41D6E0CED14D79F31C45062 /* HoverCursorModifier.swift */; }; + 1AA328A921EF8A7FDD03119A /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75B048B19C5219862BBED2E7 /* TestHelpers.swift */; }; + 269B93D5543770B464DFB37A /* TaskStoreOrderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3A2DDCE24E54ABCCFBBD4C /* TaskStoreOrderingTests.swift */; }; 3ABE52A15C2059D8D5570528 /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3DEE364304587D280C5672 /* TaskStore.swift */; }; 42E4CDE1D17463554CC4F41F /* TaskListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537A913AC421BAEF60D26D9C /* TaskListView.swift */; }; - 585A9E665730E3C4FD32AE8D /* KeyboardNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F99C67C30185163A51DCD268 /* KeyboardNavigationTests.swift */; }; 5B60B409CE4BA668DB30A65D /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; }; 614FCCA450EC0BFFD8B40640 /* ListlessMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA467DF2E59BDBE6EEF6A7D /* ListlessMacApp.swift */; }; 6C252050E62AED3A0A684EBF /* KeyboardNavigationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7B37EF6A2389656D105FF8 /* KeyboardNavigationModifier.swift */; }; @@ -23,6 +23,7 @@ 91EDF52C7C5C0B35E9D8B51E /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3DEE364304587D280C5672 /* TaskStore.swift */; }; 96617677059FABDBB80D642B /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; }; 978DABFA617B6EBD6FA179CD /* ClickableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97A08C589AB65ED2CB26A092 /* ClickableTextField.swift */; }; + 99977BFA37FBAAA49AF6B71E /* TaskStoreEdgeCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B2BBE01D99CDA278BCB9F49 /* TaskStoreEdgeCaseTests.swift */; }; A0AA8FD4C542E9AEB2437BC2 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */; }; A8D13DD6196772ECA146CF2A /* PlatformScrollIndicatorsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D74AE2501F35974F57D21F /* PlatformScrollIndicatorsModifier.swift */; }; C1BD61D6DFA687E9CB9ACA60 /* TaskRowDragGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138612F350ECE29526F689B9 /* TaskRowDragGesture.swift */; }; @@ -33,17 +34,19 @@ D8EFF49E9156083D675D47F0 /* KeyboardNavigationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7B37EF6A2389656D105FF8 /* KeyboardNavigationModifier.swift */; }; DC71C45D92524C3893BF9FDB /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81880970CCFC2CB00B65047E /* PlatformTextFieldWidthModifier.swift */; }; DC73A39A269AB495BCE1AC48 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */; }; + ECD5E7EA05AE1C00B38C939E /* TaskStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 967F7ECEB3915CEDCE584872 /* TaskStoreTests.swift */; }; F0B2B806BD84A4F2FDF8E038 /* ListlessiOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC245331D715EA85887C0BA0 /* ListlessiOSApp.swift */; }; + F50E8B8F73F64D8E641DC74C /* TaskStoreCompletionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E88DDD119EEEECCD45F36D2 /* TaskStoreCompletionTests.swift */; }; FEC96DAA2BF8BB5D6D8504EB /* HoverCursorModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82A3509AD32A54434BCC8017 /* HoverCursorModifier.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ - CBF0AD7D47832BDE6A4FB05B /* PBXContainerItemProxy */ = { + 6F7E207AE10E1516EF8A683C /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 3256C2BF8F1DAF371DA32120 /* Project object */; proxyType = 1; - remoteGlobalIDString = 0FB4F07A37999BBC6DFE4DBB; - remoteInfo = "Listless macOS"; + remoteGlobalIDString = 34A03D42B91730DEAC2EBD8E; + remoteInfo = "Listless iOS"; }; /* End PBXContainerItemProxy section */ @@ -56,29 +59,34 @@ 138612F350ECE29526F689B9 /* TaskRowDragGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowDragGesture.swift; sourceTree = "<group>"; }; 1B3D0B4710C7A0EE4F227851 /* TaskRowDragGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowDragGesture.swift; sourceTree = "<group>"; }; 1DA467DF2E59BDBE6EEF6A7D /* ListlessMacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListlessMacApp.swift; sourceTree = "<group>"; }; + 2D3A2DDCE24E54ABCCFBBD4C /* TaskStoreOrderingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStoreOrderingTests.swift; sourceTree = "<group>"; }; 3313FEDB101EECA4B344EEF4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; - 3F0FC3AFB591F222FB63C13B /* Listless macOS UITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Listless macOS UITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 48AE1AE43296C1692FA6F755 /* PlatformScrollIndicatorsModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformScrollIndicatorsModifier.swift; sourceTree = "<group>"; }; + 4FC64B9F9370041BEDBD1E14 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; }; 537A913AC421BAEF60D26D9C /* TaskListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListView.swift; sourceTree = "<group>"; }; 5B0E22B8F7B2B7283CAF749E /* Listless macOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Listless macOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 5E88DDD119EEEECCD45F36D2 /* TaskStoreCompletionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStoreCompletionTests.swift; sourceTree = "<group>"; }; 74255E6B6C40899E9B17D927 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; }; + 75B048B19C5219862BBED2E7 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; }; 7C73E9D4C42CCABBF0F33543 /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; }; 81880970CCFC2CB00B65047E /* PlatformTextFieldWidthModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformTextFieldWidthModifier.swift; sourceTree = "<group>"; }; 82A3509AD32A54434BCC8017 /* HoverCursorModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoverCursorModifier.swift; sourceTree = "<group>"; }; 9262207DAC21619BD9EDEE15 /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; }; 944BAE054AAC1B9C4FC954F9 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; }; + 967F7ECEB3915CEDCE584872 /* TaskStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStoreTests.swift; sourceTree = "<group>"; }; 97A08C589AB65ED2CB26A092 /* ClickableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickableTextField.swift; sourceTree = "<group>"; }; - 992AE558C5BBEE16D70B8FF4 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; }; + 9B2BBE01D99CDA278BCB9F49 /* TaskStoreEdgeCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStoreEdgeCaseTests.swift; sourceTree = "<group>"; }; 9FBEB58DD41817F09B0EB9F0 /* Listless.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Listless.xcdatamodel; sourceTree = "<group>"; }; AC245331D715EA85887C0BA0 /* ListlessiOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListlessiOSApp.swift; sourceTree = "<group>"; }; B4D74AE2501F35974F57D21F /* PlatformScrollIndicatorsModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformScrollIndicatorsModifier.swift; sourceTree = "<group>"; }; C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; }; + C71466C5CD1A5BA984352F8D /* Listless iOS Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Listless iOS Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + C9B14DC786A336008AAB78EE /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; }; D10B5491A53E77C80F8F75CD /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; }; D123BB181208FC825777B0A7 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; }; D41D6E0CED14D79F31C45062 /* HoverCursorModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoverCursorModifier.swift; sourceTree = "<group>"; }; DC3DEE364304587D280C5672 /* TaskStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStore.swift; sourceTree = "<group>"; }; EF6B0195EA0176B72DE9B092 /* PlatformTextFieldWidthModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformTextFieldWidthModifier.swift; sourceTree = "<group>"; }; - F99C67C30185163A51DCD268 /* KeyboardNavigationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardNavigationTests.swift; sourceTree = "<group>"; }; FBB8A3BEB346267B30B4675F /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ @@ -97,8 +105,8 @@ 3936BDEE64D16E6C4C85B3DD /* Products */ = { isa = PBXGroup; children = ( + C71466C5CD1A5BA984352F8D /* Listless iOS Unit Tests.xctest */, 126108860D7878DDC3BECC4B /* Listless iOS.app */, - 3F0FC3AFB591F222FB63C13B /* Listless macOS UITests.xctest */, 5B0E22B8F7B2B7283CAF749E /* Listless macOS.app */, ); name = Products; @@ -149,15 +157,6 @@ path = ListlessiOS; sourceTree = "<group>"; }; - A4CAFE5C963E75C444972E10 /* ListlessMacUITests */ = { - isa = PBXGroup; - children = ( - F99C67C30185163A51DCD268 /* KeyboardNavigationTests.swift */, - 992AE558C5BBEE16D70B8FF4 /* README.md */, - ); - path = ListlessMacUITests; - sourceTree = "<group>"; - }; AA563E991F69ED14DCD9A1AB /* Infrastructure */ = { isa = PBXGroup; children = ( @@ -189,6 +188,16 @@ path = ListlessMac; sourceTree = "<group>"; }; + D98A7EE79F8E29297555B801 /* Support */ = { + isa = PBXGroup; + children = ( + 4FC64B9F9370041BEDBD1E14 /* .gitkeep */, + 75B048B19C5219862BBED2E7 /* TestHelpers.swift */, + ); + name = Support; + path = Tests/Support; + sourceTree = "<group>"; + }; E98EF3B4638A9E8473EA62FA /* Sync */ = { isa = PBXGroup; children = ( @@ -204,11 +213,25 @@ 58051CBDE2390F9E13647235 /* Listless */, 954AAB8DDFF6E6D6FD6A0A2C /* ListlessiOS */, D5B197AFF26144948D032299 /* ListlessMac */, - A4CAFE5C963E75C444972E10 /* ListlessMacUITests */, + D98A7EE79F8E29297555B801 /* Support */, + F63E6E98ABFB43E1B32686B4 /* Unit */, 3936BDEE64D16E6C4C85B3DD /* Products */, ); sourceTree = "<group>"; }; + F63E6E98ABFB43E1B32686B4 /* Unit */ = { + isa = PBXGroup; + children = ( + C9B14DC786A336008AAB78EE /* .gitkeep */, + 5E88DDD119EEEECCD45F36D2 /* TaskStoreCompletionTests.swift */, + 9B2BBE01D99CDA278BCB9F49 /* TaskStoreEdgeCaseTests.swift */, + 2D3A2DDCE24E54ABCCFBBD4C /* TaskStoreOrderingTests.swift */, + 967F7ECEB3915CEDCE584872 /* TaskStoreTests.swift */, + ); + name = Unit; + path = Tests/Unit; + sourceTree = "<group>"; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -246,24 +269,23 @@ productReference = 126108860D7878DDC3BECC4B /* Listless iOS.app */; productType = "com.apple.product-type.application"; }; - 4DDF5F8F5508AA35A41BECAB /* Listless macOS UITests */ = { + D533FDEDCE6DFCA2E8CB70F5 /* Listless iOS Unit Tests */ = { isa = PBXNativeTarget; - buildConfigurationList = 9FF482EB568895AF0B3F35CC /* Build configuration list for PBXNativeTarget "Listless macOS UITests" */; + buildConfigurationList = 1993E9E4443DC6AB506DE24C /* Build configuration list for PBXNativeTarget "Listless iOS Unit Tests" */; buildPhases = ( - 34DA2E4A88D1AFCE78FCE279 /* Sources */, - ED7DBE75C614608E5D3B630F /* Resources */, + D959A967B1BB3E9246C006D7 /* Sources */, ); buildRules = ( ); dependencies = ( - 180D7333E25210955007E660 /* PBXTargetDependency */, + 03DCFDF6E769DBF0DBC470F6 /* PBXTargetDependency */, ); - name = "Listless macOS UITests"; + name = "Listless iOS Unit Tests"; packageProductDependencies = ( ); - productName = "Listless macOS UITests"; - productReference = 3F0FC3AFB591F222FB63C13B /* Listless macOS UITests.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; + productName = "Listless iOS Unit Tests"; + productReference = C71466C5CD1A5BA984352F8D /* Listless iOS Unit Tests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; }; /* End PBXNativeTarget section */ @@ -282,10 +304,9 @@ DevelopmentTeam = 7TD7PZBNXP; ProvisioningStyle = Automatic; }; - 4DDF5F8F5508AA35A41BECAB = { + D533FDEDCE6DFCA2E8CB70F5 = { DevelopmentTeam = 7TD7PZBNXP; ProvisioningStyle = Automatic; - TestTargetID = 0FB4F07A37999BBC6DFE4DBB; }; }; }; @@ -304,32 +325,13 @@ projectRoot = ""; targets = ( 34A03D42B91730DEAC2EBD8E /* Listless iOS */, + D533FDEDCE6DFCA2E8CB70F5 /* Listless iOS Unit Tests */, 0FB4F07A37999BBC6DFE4DBB /* Listless macOS */, - 4DDF5F8F5508AA35A41BECAB /* Listless macOS UITests */, ); }; /* End PBXProject section */ -/* Begin PBXResourcesBuildPhase section */ - ED7DBE75C614608E5D3B630F /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 031B814FDF3F9B20E3602563 /* README.md in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - /* Begin PBXSourcesBuildPhase section */ - 34DA2E4A88D1AFCE78FCE279 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 585A9E665730E3C4FD32AE8D /* KeyboardNavigationTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; 409D108909CBEC2F69B56D0E /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -370,36 +372,29 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D959A967B1BB3E9246C006D7 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F50E8B8F73F64D8E641DC74C /* TaskStoreCompletionTests.swift in Sources */, + 99977BFA37FBAAA49AF6B71E /* TaskStoreEdgeCaseTests.swift in Sources */, + 269B93D5543770B464DFB37A /* TaskStoreOrderingTests.swift in Sources */, + ECD5E7EA05AE1C00B38C939E /* TaskStoreTests.swift in Sources */, + 1AA328A921EF8A7FDD03119A /* TestHelpers.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - 180D7333E25210955007E660 /* PBXTargetDependency */ = { + 03DCFDF6E769DBF0DBC470F6 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = 0FB4F07A37999BBC6DFE4DBB /* Listless macOS */; - targetProxy = CBF0AD7D47832BDE6A4FB05B /* PBXContainerItemProxy */; + target = 34A03D42B91730DEAC2EBD8E /* Listless iOS */; + targetProxy = 6F7E207AE10E1516EF8A683C /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ - 086CB8FDA1EC9D4A988C382E /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = 7TD7PZBNXP; - GENERATE_INFOPLIST_FILE = YES; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = net.inqk.listless.mac.uitests; - SDKROOT = macosx; - TEST_TARGET_NAME = "Listless macOS"; - }; - name = Release; - }; 2D4E3CC5FF8E6299F754CCFC /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -418,25 +413,6 @@ }; name = Debug; }; - 31AB47694981749AC7C9E541 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = 7TD7PZBNXP; - GENERATE_INFOPLIST_FILE = YES; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = net.inqk.listless.mac.uitests; - SDKROOT = macosx; - TEST_TARGET_NAME = "Listless macOS"; - }; - name = Debug; - }; A5A377EA0FE470803E2B6BA1 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -511,6 +487,25 @@ }; name = Release; }; + C00090C364E4A0B81DC878F0 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 7TD7PZBNXP; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = net.inqk.listless.ios.unittests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Listless iOS.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Listless iOS"; + }; + name = Debug; + }; D1B900A3E25ED5B39F0D9716 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -593,6 +588,25 @@ }; name = Debug; }; + F32B53087C3BB2F10323145F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 7TD7PZBNXP; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = net.inqk.listless.ios.unittests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Listless iOS.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Listless iOS"; + }; + name = Release; + }; F9DCF7B07CCBB6881C4642BD /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -615,20 +629,20 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 4D46AAEA40BF3A5A8C4E1999 /* Build configuration list for PBXNativeTarget "Listless macOS" */ = { + 1993E9E4443DC6AB506DE24C /* Build configuration list for PBXNativeTarget "Listless iOS Unit Tests" */ = { isa = XCConfigurationList; buildConfigurations = ( - 2D4E3CC5FF8E6299F754CCFC /* Debug */, - A5A377EA0FE470803E2B6BA1 /* Release */, + C00090C364E4A0B81DC878F0 /* Debug */, + F32B53087C3BB2F10323145F /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; - 9FF482EB568895AF0B3F35CC /* Build configuration list for PBXNativeTarget "Listless macOS UITests" */ = { + 4D46AAEA40BF3A5A8C4E1999 /* Build configuration list for PBXNativeTarget "Listless macOS" */ = { isa = XCConfigurationList; buildConfigurations = ( - 31AB47694981749AC7C9E541 /* Debug */, - 086CB8FDA1EC9D4A988C382E /* Release */, + 2D4E3CC5FF8E6299F754CCFC /* Debug */, + A5A377EA0FE470803E2B6BA1 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; diff --git a/Listless.xcodeproj/xcshareddata/xcschemes/Listless iOS.xcscheme b/Listless.xcodeproj/xcshareddata/xcschemes/Listless iOS.xcscheme @@ -39,7 +39,21 @@ </BuildableReference> </MacroExpansion> <Testables> + <TestableReference + skipped = "NO" + parallelizable = "YES" + testExecutionOrdering = "random"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "D533FDEDCE6DFCA2E8CB70F5" + BuildableName = "Listless iOS Unit Tests.xctest" + BlueprintName = "Listless iOS Unit Tests" + ReferencedContainer = "container:Listless.xcodeproj"> + </BuildableReference> + </TestableReference> </Testables> + <CommandLineArguments> + </CommandLineArguments> </TestAction> <LaunchAction buildConfiguration = "Debug" diff --git a/Listless.xcodeproj/xcshareddata/xcschemes/Listless macOS.xcscheme b/Listless.xcodeproj/xcshareddata/xcschemes/Listless macOS.xcscheme @@ -39,20 +39,7 @@ </BuildableReference> </MacroExpansion> <Testables> - <TestableReference - skipped = "NO" - parallelizable = "NO"> - <BuildableReference - BuildableIdentifier = "primary" - BlueprintIdentifier = "4DDF5F8F5508AA35A41BECAB" - BuildableName = "Listless macOS UITests.xctest" - BlueprintName = "Listless macOS UITests" - ReferencedContainer = "container:Listless.xcodeproj"> - </BuildableReference> - </TestableReference> </Testables> - <CommandLineArguments> - </CommandLineArguments> </TestAction> <LaunchAction buildConfiguration = "Debug" diff --git a/Listless/Models/TaskStore.swift b/Listless/Models/TaskStore.swift @@ -16,13 +16,20 @@ final class TaskStore { func fetchTasks() -> [TaskItem] { let request = TaskItem.fetchRequest() - request.sortDescriptors = [ - NSSortDescriptor(keyPath: \TaskItem.isCompleted, ascending: true), - NSSortDescriptor(keyPath: \TaskItem.createdAt, ascending: true), - ] + request.sortDescriptors = [] do { - return try context.fetch(request) + let allTasks = try context.fetch(request) + + // Active tasks sorted by sortOrder + let activeTasks = allTasks.filter { !$0.isCompleted } + .sorted { $0.sortOrder < $1.sortOrder } + + // Completed tasks sorted by updatedAt (most recently completed last) + let completedTasks = allTasks.filter { $0.isCompleted } + .sorted { $0.updatedAt < $1.updatedAt } + + return activeTasks + completedTasks } catch { print("Failed to fetch tasks: \(error)") return [] @@ -34,8 +41,9 @@ final class TaskStore { task.title = title // Set sortOrder to end of active tasks + context.processPendingChanges() let activeTasks = fetchTasks().filter { !$0.isCompleted } - let maxOrder = activeTasks.map(\.sortOrder).max() ?? 0 + let maxOrder = activeTasks.map(\.sortOrder).max() ?? -1000 task.sortOrder = maxOrder + 1000 save() @@ -82,8 +90,8 @@ final class TaskStore { var reordered = activeTasks let task = reordered.remove(at: currentIndex) - // Clamp toIndex to valid range after removal - let insertIndex = min(toIndex, reordered.count) + // Clamp toIndex to valid range [0, reordered.count] after removal + let insertIndex = max(0, min(toIndex, reordered.count)) reordered.insert(task, at: insertIndex) // Reassign sortOrder with gaps of 1000 diff --git a/ListlessiOS/Views/ClickableTextField.swift b/ListlessiOS/Views/ClickableTextField.swift @@ -4,9 +4,10 @@ import SwiftUI struct ClickableTextField: View { @Binding var text: String let isCompleted: Bool - let onEditingChanged: (Bool) -> Void + let onEditingChanged: (Bool, _ shouldCreateNewTask: Bool) -> Void @FocusState private var isFocused: Bool + @State private var submittedViaReturn = false var body: some View { TextField("New task", text: $text, axis: .vertical) @@ -15,12 +16,20 @@ struct ClickableTextField: View { .lineLimit(1...5) .focused($isFocused) .onSubmit { - // Resign focus when Return is pressed (same as focus loss) + // Return key pressed - mark for new task creation + submittedViaReturn = true isFocused = false } .disabled(isCompleted) .onChange(of: isFocused) { _, newValue in - onEditingChanged(newValue) + if newValue { + // Focus gained + onEditingChanged(true, false) + } else { + // Focus lost - check if it was via Return key + onEditingChanged(false, submittedViaReturn) + submittedViaReturn = false + } } } } diff --git a/Tests/Support/TestHelpers.swift b/Tests/Support/TestHelpers.swift @@ -0,0 +1,37 @@ +import Foundation +import Testing + +@testable import Listless_iOS + +/// Creates a fresh TaskStore with in-memory persistence for isolated testing. +@MainActor +func makeTestStore() -> TaskStore { + let controller = PersistenceController(inMemory: true) + return TaskStore(persistenceController: controller) +} + +/// Creates a TaskStore pre-populated with test tasks. +/// - Parameters: +/// - count: Number of tasks to create (default: 3) +/// - titles: Optional array of titles; if nil, generates "Task 1", "Task 2", etc. +/// - Returns: Tuple of (store, array of created task IDs) +@MainActor +func makeTestStoreWithTasks(count: Int = 3, titles: [String]? = nil) -> (TaskStore, [UUID]) { + let store = makeTestStore() + var taskIDs: [UUID] = [] + + for i in 0..<count { + let title = titles?[safe: i] ?? "Task \(i + 1)" + let task = store.createTask(title: title) + taskIDs.append(task.id) + } + + return (store, taskIDs) +} + +/// Safe array subscript that returns nil instead of crashing on out-of-bounds access. +extension Array { + subscript(safe index: Int) -> Element? { + return indices.contains(index) ? self[index] : nil + } +} diff --git a/Tests/Unit/TaskStoreCompletionTests.swift b/Tests/Unit/TaskStoreCompletionTests.swift @@ -0,0 +1,148 @@ +import Foundation +import Testing + +@testable import Listless_iOS + +@Suite("TaskStore Completion Behavior", .serialized) +@MainActor +struct TaskStoreCompletionTests { + + // MARK: - Basic Completion Tests + + @Test("Complete task") + func completeTask() async throws { + let store = makeTestStore() + let task = store.createTask(title: "Task to complete") + + store.complete(taskID: task.id) + + let tasks = store.fetchTasks() + #expect(tasks.first?.isCompleted == true) + } + + @Test("Uncomplete task") + func uncompleteTask() async throws { + let store = makeTestStore() + let task = store.createTask(title: "Task") + store.complete(taskID: task.id) + + store.uncomplete(taskID: task.id) + + let tasks = store.fetchTasks() + #expect(tasks.first?.isCompleted == false) + } + + @Test("Complete with invalid ID does nothing") + func completeWithInvalidIDDoesNothing() async throws { + let store = makeTestStore() + let task = store.createTask(title: "Task") + let invalidID = UUID() + + store.complete(taskID: invalidID) + + let tasks = store.fetchTasks() + #expect(tasks.first?.isCompleted == false) + } + + @Test("Uncomplete with invalid ID does nothing") + func uncompleteWithInvalidIDDoesNothing() async throws { + let store = makeTestStore() + let task = store.createTask(title: "Task") + store.complete(taskID: task.id) + let invalidID = UUID() + + store.uncomplete(taskID: invalidID) + + let tasks = store.fetchTasks() + #expect(tasks.first?.isCompleted == true) + } + + // MARK: - Timestamp Tests + + @Test("Completing task updates timestamp") + func completingTaskUpdatesTimestamp() async throws { + let store = makeTestStore() + let task = store.createTask(title: "Task") + let originalUpdatedAt = task.updatedAt + + // Small delay to ensure timestamp difference + try await Task.sleep(nanoseconds: 10_000_000) // 10ms + + store.complete(taskID: task.id) + + let tasks = store.fetchTasks() + let updatedTask = tasks.first + #expect(updatedTask?.updatedAt ?? Date() > originalUpdatedAt) + } + + // MARK: - Sorting Tests + + @Test("Active tasks appear before completed tasks") + func activeTasksAppearBeforeCompletedTasks() async throws { + let store = makeTestStore() + let task1 = store.createTask(title: "Task 1") + let task2 = store.createTask(title: "Task 2") + let task3 = store.createTask(title: "Task 3") + + store.complete(taskID: task2.id) + + let tasks = store.fetchTasks() + #expect(tasks[0].id == task1.id) + #expect(tasks[1].id == task3.id) + #expect(tasks[2].id == task2.id) + } + + @Test("Completed tasks sorted by updatedAt") + func completedTasksSortedByUpdatedAt() async throws { + let store = makeTestStore() + let task1 = store.createTask(title: "Task 1") + let task2 = store.createTask(title: "Task 2") + let task3 = store.createTask(title: "Task 3") + + // Complete in specific order with delays + store.complete(taskID: task2.id) + try await Task.sleep(nanoseconds: 10_000_000) // 10ms + + store.complete(taskID: task1.id) + try await Task.sleep(nanoseconds: 10_000_000) // 10ms + + store.complete(taskID: task3.id) + + let tasks = store.fetchTasks() + // All completed, should be sorted by updatedAt (completion order) + #expect(tasks[0].id == task2.id) + #expect(tasks[1].id == task1.id) + #expect(tasks[2].id == task3.id) + } + + @Test("Toggle completion multiple times") + func toggleCompletionMultipleTimes() async throws { + let store = makeTestStore() + let task = store.createTask(title: "Task") + + store.complete(taskID: task.id) + var tasks = store.fetchTasks() + #expect(tasks.first?.isCompleted == true) + + store.uncomplete(taskID: task.id) + tasks = store.fetchTasks() + #expect(tasks.first?.isCompleted == false) + + store.complete(taskID: task.id) + tasks = store.fetchTasks() + #expect(tasks.first?.isCompleted == true) + } + + @Test("Complete all tasks") + func completeAllTasks() async throws { + let (store, taskIDs) = makeTestStoreWithTasks(count: 5) + + for id in taskIDs { + store.complete(taskID: id) + } + + let tasks = store.fetchTasks() + #expect(tasks.allSatisfy { $0.isCompleted }) + #expect(tasks.count == 5) + } +} diff --git a/Tests/Unit/TaskStoreEdgeCaseTests.swift b/Tests/Unit/TaskStoreEdgeCaseTests.swift @@ -0,0 +1,163 @@ +import Foundation +import Testing + +@testable import Listless_iOS + +@Suite("TaskStore Edge Cases", .serialized) +@MainActor +struct TaskStoreEdgeCaseTests { + + // MARK: - Title Edge Cases + + @Test("Task with empty title") + func taskWithEmptyTitle() async throws { + let store = makeTestStore() + + let task = store.createTask(title: "") + + let tasks = store.fetchTasks() + #expect(tasks.first?.title == "") + } + + @Test("Task with very long title") + func taskWithVeryLongTitle() async throws { + let store = makeTestStore() + let longTitle = String(repeating: "A", count: 10_000) + + let task = store.createTask(title: longTitle) + + let tasks = store.fetchTasks() + #expect(tasks.first?.title.count == 10_000) + } + + @Test("Task with special characters") + func taskWithSpecialCharacters() async throws { + let store = makeTestStore() + let specialTitle = "Test 🎉 with émojis & spëcial çharacters! @#$%^&*()" + + let task = store.createTask(title: specialTitle) + + let tasks = store.fetchTasks() + #expect(tasks.first?.title == specialTitle) + } + + @Test("Task with newlines and tabs") + func taskWithNewlinesAndTabs() async throws { + let store = makeTestStore() + let multilineTitle = "Line 1\nLine 2\tTabbed" + + let task = store.createTask(title: multilineTitle) + + let tasks = store.fetchTasks() + #expect(tasks.first?.title == multilineTitle) + } + + // MARK: - Large Data Sets + + @Test("Create many tasks") + func createManyTasks() async throws { + let store = makeTestStore() + let count = 100 + + for i in 0..<count { + _ = store.createTask(title: "Task \(i)") + } + + let tasks = store.fetchTasks() + #expect(tasks.count == count) + } + + @Test("Delete all tasks from large set") + func deleteAllTasksFromLargeSet() async throws { + let store = makeTestStore() + var taskIDs: [UUID] = [] + + for i in 0..<50 { + let task = store.createTask(title: "Task \(i)") + taskIDs.append(task.id) + } + + for id in taskIDs { + store.delete(taskID: id) + } + + let tasks = store.fetchTasks() + #expect(tasks.isEmpty) + } + + // MARK: - State Transitions + + @Test("Create task after completing all tasks") + func createTaskAfterCompletingAllTasks() async throws { + let (store, taskIDs) = makeTestStoreWithTasks(count: 3) + + for id in taskIDs { + store.complete(taskID: id) + } + + let newTask = store.createTask(title: "New task") + + let tasks = store.fetchTasks() + let activeTasks = tasks.filter { !$0.isCompleted } + #expect(activeTasks.count == 1) + #expect(activeTasks[0].id == newTask.id) + } + + @Test("Rapid updates to same task") + func rapidUpdatesToSameTask() async throws { + let store = makeTestStore() + let task = store.createTask(title: "Original") + + for i in 0..<10 { + store.update(taskID: task.id, title: "Update \(i)") + } + + let tasks = store.fetchTasks() + #expect(tasks.first?.title == "Update 9") + } + + // MARK: - Store State Tests + + @Test("Store with only completed tasks") + func storeWithOnlyCompletedTasks() async throws { + let (store, taskIDs) = makeTestStoreWithTasks(count: 5) + + for id in taskIDs { + store.complete(taskID: id) + } + + let tasks = store.fetchTasks() + #expect(tasks.allSatisfy { $0.isCompleted }) + #expect(tasks.count == 5) + } + + @Test("SortOrder after completing all tasks") + func sortOrderAfterCompletingAllTasks() async throws { + let (store, taskIDs) = makeTestStoreWithTasks(count: 3) + + for id in taskIDs { + store.complete(taskID: id) + } + + let newTask1 = store.createTask(title: "New 1") + let newTask2 = store.createTask(title: "New 2") + + let activeTasks = store.fetchTasks().filter { !$0.isCompleted } + #expect(activeTasks[0].id == newTask1.id) + #expect(activeTasks[1].id == newTask2.id) + #expect(activeTasks[1].sortOrder > activeTasks[0].sortOrder) + } + + @Test("Uncompleting task moves it back to active") + func uncompletingTaskMovesItBackToActive() async throws { + let (store, taskIDs) = makeTestStoreWithTasks(count: 3) + store.complete(taskID: taskIDs[1]) + + store.uncomplete(taskID: taskIDs[1]) + + let tasks = store.fetchTasks() + let activeTasks = tasks.filter { !$0.isCompleted } + #expect(activeTasks.count == 3) + #expect(activeTasks.contains { $0.id == taskIDs[1] }) + } +} diff --git a/Tests/Unit/TaskStoreOrderingTests.swift b/Tests/Unit/TaskStoreOrderingTests.swift @@ -0,0 +1,186 @@ +import Foundation +import Testing + +@testable import Listless_iOS + +@Suite("TaskStore Task Reordering", .serialized) +@MainActor +struct TaskStoreOrderingTests { + + // MARK: - Initial State Tests + + @Test("Initial sortOrder has 1000-point gaps") + func initialSortOrderHasThousandPointGaps() async throws { + let store = makeTestStore() + + let task1 = store.createTask(title: "Task 1") + let task2 = store.createTask(title: "Task 2") + let task3 = store.createTask(title: "Task 3") + + let tasks = store.fetchTasks() + + // All tasks are active, so they should be the first 3 + #expect(tasks.count == 3) + + // Verify tasks are in ascending order + #expect(tasks[0].sortOrder < tasks[1].sortOrder) + #expect(tasks[1].sortOrder < tasks[2].sortOrder) + + // Verify 1000-point gaps between tasks + #expect(tasks[1].sortOrder - tasks[0].sortOrder == 1000) + #expect(tasks[2].sortOrder - tasks[1].sortOrder == 1000) + } + + // MARK: - Move Tests (Parameterized) + + @Test("Move task to different positions", arguments: [ + (from: 0, to: 2), + (from: 2, to: 0), + (from: 0, to: 1), + (from: 1, to: 0), + (from: 1, to: 2), + (from: 2, to: 1), + ]) + func moveTaskToDifferentPositions(from: Int, to: Int) async throws { + let (store, taskIDs) = makeTestStoreWithTasks(count: 3) + let taskToMove = taskIDs[from] + + store.moveTask(taskID: taskToMove, toIndex: to) + + let tasks = store.fetchTasks().filter { !$0.isCompleted } + #expect(tasks[to].id == taskToMove) + } + + // MARK: - Order Preservation Tests + + @Test("Moving maintains 1000-point gaps") + func movingMaintainsThousandPointGaps() async throws { + let (store, taskIDs) = makeTestStoreWithTasks(count: 4) + + store.moveTask(taskID: taskIDs[0], toIndex: 2) + + let tasks = store.fetchTasks().filter { !$0.isCompleted } + #expect(tasks[0].sortOrder == 0) + #expect(tasks[1].sortOrder == 1000) + #expect(tasks[2].sortOrder == 2000) + #expect(tasks[3].sortOrder == 3000) + } + + @Test("Move task to same index does nothing") + func moveTaskToSameIndexDoesNothing() async throws { + let (store, taskIDs) = makeTestStoreWithTasks(count: 3) + let originalTasks = store.fetchTasks().filter { !$0.isCompleted } + + store.moveTask(taskID: taskIDs[1], toIndex: 1) + + let tasks = store.fetchTasks().filter { !$0.isCompleted } + #expect(tasks[0].id == originalTasks[0].id) + #expect(tasks[1].id == originalTasks[1].id) + #expect(tasks[2].id == originalTasks[2].id) + } + + // MARK: - Invalid Input Tests + + @Test("Move with invalid ID does nothing") + func moveWithInvalidIDDoesNothing() async throws { + let (store, taskIDs) = makeTestStoreWithTasks(count: 3) + let originalTasks = store.fetchTasks().filter { !$0.isCompleted } + let invalidID = UUID() + + store.moveTask(taskID: invalidID, toIndex: 0) + + let tasks = store.fetchTasks().filter { !$0.isCompleted } + #expect(tasks[0].id == originalTasks[0].id) + #expect(tasks[1].id == originalTasks[1].id) + #expect(tasks[2].id == originalTasks[2].id) + } + + @Test("Move to negative index clamps to 0") + func moveToNegativeIndexClampsToZero() async throws { + let (store, taskIDs) = makeTestStoreWithTasks(count: 3) + + store.moveTask(taskID: taskIDs[2], toIndex: -5) + + let tasks = store.fetchTasks().filter { !$0.isCompleted } + #expect(tasks[0].id == taskIDs[2]) + } + + @Test("Move to out-of-bounds index clamps to end") + func moveToOutOfBoundsIndexClampsToEnd() async throws { + let (store, taskIDs) = makeTestStoreWithTasks(count: 3) + + store.moveTask(taskID: taskIDs[0], toIndex: 999) + + let tasks = store.fetchTasks().filter { !$0.isCompleted } + #expect(tasks[2].id == taskIDs[0]) + } + + // MARK: - Completed Task Tests + + @Test("Moving only affects active tasks") + func movingOnlyAffectsActiveTasks() async throws { + let (store, taskIDs) = makeTestStoreWithTasks(count: 4) + store.complete(taskID: taskIDs[3]) + + store.moveTask(taskID: taskIDs[0], toIndex: 2) + + let allTasks = store.fetchTasks() + let activeTasks = allTasks.filter { !$0.isCompleted } + let completedTasks = allTasks.filter { $0.isCompleted } + + #expect(activeTasks.count == 3) + #expect(completedTasks.count == 1) + #expect(completedTasks[0].id == taskIDs[3]) + } + + @Test("Moving completed task does nothing") + func movingCompletedTaskDoesNothing() async throws { + let (store, taskIDs) = makeTestStoreWithTasks(count: 3) + store.complete(taskID: taskIDs[0]) + let originalTasks = store.fetchTasks() + + store.moveTask(taskID: taskIDs[0], toIndex: 1) + + let tasks = store.fetchTasks() + #expect(tasks[0].id == originalTasks[0].id) + #expect(tasks[1].id == originalTasks[1].id) + #expect(tasks[2].id == originalTasks[2].id) + } + + // MARK: - Edge Cases + + @Test("Move single task does nothing") + func moveSingleTaskDoesNothing() async throws { + let store = makeTestStore() + let task = store.createTask(title: "Only task") + + store.moveTask(taskID: task.id, toIndex: 0) + + let tasks = store.fetchTasks() + #expect(tasks.count == 1) + #expect(tasks[0].id == task.id) + } + + @Test("Move in empty store does nothing") + func moveInEmptyStoreDoesNothing() async throws { + let store = makeTestStore() + let randomID = UUID() + + store.moveTask(taskID: randomID, toIndex: 0) + + let tasks = store.fetchTasks() + #expect(tasks.isEmpty) + } + + @Test("Multiple moves maintain order") + func multipleMoveMaintainOrder() async throws { + let (store, taskIDs) = makeTestStoreWithTasks(count: 4) + + store.moveTask(taskID: taskIDs[0], toIndex: 3) + store.moveTask(taskID: taskIDs[2], toIndex: 0) + + let tasks = store.fetchTasks().filter { !$0.isCompleted } + #expect(tasks[0].id == taskIDs[2]) + #expect(tasks[3].id == taskIDs[0]) + } +} diff --git a/Tests/Unit/TaskStoreTests.swift b/Tests/Unit/TaskStoreTests.swift @@ -0,0 +1,189 @@ +import Foundation +import Testing + +@testable import Listless_iOS + +@Suite("TaskStore CRUD Operations", .serialized) +@MainActor +struct TaskStoreTests { + + // MARK: - Creation Tests + + @Test("Create task with empty title") + func createTaskWithEmptyTitle() async throws { + let store = makeTestStore() + + let task = store.createTask() + + #expect(task.title == "") + #expect(task.id != UUID(uuidString: "00000000-0000-0000-0000-000000000000")!) + #expect(task.isCompleted == false) + #expect(task.createdAt.timeIntervalSinceNow > -1.0) + } + + @Test("Create task with title") + func createTaskWithTitle() async throws { + let store = makeTestStore() + + let task = store.createTask(title: "Buy groceries") + + #expect(task.title == "Buy groceries") + #expect(task.id != UUID(uuidString: "00000000-0000-0000-0000-000000000000")!) + } + + @Test("Create multiple tasks with unique IDs") + func createMultipleTasksWithUniqueIDs() async throws { + let store = makeTestStore() + + let task1 = store.createTask(title: "Task 1") + let task2 = store.createTask(title: "Task 2") + let task3 = store.createTask(title: "Task 3") + + #expect(task1.id != task2.id) + #expect(task2.id != task3.id) + #expect(task1.id != task3.id) + } + + @Test("Created task has timestamps") + func createdTaskHasTimestamps() async throws { + let store = makeTestStore() + + let beforeCreate = Date() + let task = store.createTask(title: "Test") + let afterCreate = Date() + + #expect(task.createdAt >= beforeCreate) + #expect(task.createdAt <= afterCreate) + #expect(task.updatedAt >= beforeCreate) + #expect(task.updatedAt <= afterCreate) + } + + // MARK: - Fetch Tests + + @Test("Fetch tasks from empty store") + func fetchTasksFromEmptyStore() async throws { + let store = makeTestStore() + + let tasks = store.fetchTasks() + + #expect(tasks.isEmpty) + } + + @Test("Fetch tasks returns created tasks") + func fetchTasksReturnsCreatedTasks() async throws { + let store = makeTestStore() + _ = store.createTask(title: "Task 1") + _ = store.createTask(title: "Task 2") + + let tasks = store.fetchTasks() + + #expect(tasks.count == 2) + #expect(tasks[0].title == "Task 1") + #expect(tasks[1].title == "Task 2") + } + + // MARK: - Update Tests + + @Test("Update task title") + func updateTaskTitle() async throws { + let store = makeTestStore() + let task = store.createTask(title: "Original") + + store.update(taskID: task.id, title: "Updated") + + let tasks = store.fetchTasks() + #expect(tasks.first?.title == "Updated") + } + + @Test("Update task title without saving") + func updateTaskTitleWithoutSaving() async throws { + let store = makeTestStore() + let task = store.createTask(title: "Original") + + store.updateWithoutSaving(taskID: task.id, title: "Updated") + + let tasks = store.fetchTasks() + #expect(tasks.first?.title == "Updated") + } + + @Test("Update with invalid ID does nothing") + func updateWithInvalidIDDoesNothing() async throws { + let store = makeTestStore() + _ = store.createTask(title: "Task 1") + let invalidID = UUID() + + store.update(taskID: invalidID, title: "Should not exist") + + let tasks = store.fetchTasks() + #expect(tasks.count == 1) + #expect(tasks.first?.title == "Task 1") + } + + // MARK: - Delete Tests + + @Test("Delete task") + func deleteTask() async throws { + let store = makeTestStore() + let task1 = store.createTask(title: "Task 1") + let task2 = store.createTask(title: "Task 2") + + store.delete(taskID: task1.id) + + let tasks = store.fetchTasks() + #expect(tasks.count == 1) + #expect(tasks.first?.id == task2.id) + } + + @Test("Delete all tasks") + func deleteAllTasks() async throws { + let store = makeTestStore() + let task1 = store.createTask(title: "Task 1") + let task2 = store.createTask(title: "Task 2") + + store.delete(taskID: task1.id) + store.delete(taskID: task2.id) + + let tasks = store.fetchTasks() + #expect(tasks.isEmpty) + } + + @Test("Delete with invalid ID does nothing") + func deleteWithInvalidIDDoesNothing() async throws { + let store = makeTestStore() + _ = store.createTask(title: "Task 1") + let invalidID = UUID() + + store.delete(taskID: invalidID) + + let tasks = store.fetchTasks() + #expect(tasks.count == 1) + } + + // MARK: - Edge Cases + + @Test("Task IDs persist across fetches") + func taskIDsPersistAcrossFetches() async throws { + let store = makeTestStore() + let task = store.createTask(title: "Test") + let originalID = task.id + + let fetchedTasks = store.fetchTasks() + let fetchedID = fetchedTasks.first?.id + + #expect(fetchedID == originalID) + } + + @Test("Create task increments sortOrder") + func createTaskIncrementsSortOrder() async throws { + let store = makeTestStore() + + let task1 = store.createTask(title: "Task 1") + let task2 = store.createTask(title: "Task 2") + let task3 = store.createTask(title: "Task 3") + + #expect(task2.sortOrder > task1.sortOrder) + #expect(task3.sortOrder > task2.sortOrder) + #expect(task2.sortOrder - task1.sortOrder == 1000) + #expect(task3.sortOrder - task2.sortOrder == 1000) + } +}