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