← All repositories

listless

A simple list app for Apple platforms

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


Tests/Unit/ItemStoreEdgeCaseTests.swift (9519B)

1import Foundation
2import CoreData
3import Testing
4
5#if os(macOS)
6@testable import Listless
7#else
8@testable import Listless_iOS
9#endif
10
11@Suite("ItemStore Edge Cases", .serialized)
12@MainActor
13struct ItemStoreEdgeCaseTests {
14
15    // MARK: - Title Edge Cases
16
17    @Test("Task with empty title")
18    func itemWithEmptyTitle() async throws {
19        let store = makeTestStore()
20
21        let item = try store.createItem(title: "")
22
23        let items = try store.fetchItems()
24        #expect(items.first?.title == "")
25    }
26
27    @Test("Task with very long title")
28    func itemWithVeryLongTitle() async throws {
29        let store = makeTestStore()
30        let longTitle = String(repeating: "A", count: 10_000)
31
32        let item = try store.createItem(title: longTitle)
33
34        let items = try store.fetchItems()
35        #expect(items.first?.title.count == 10_000)
36    }
37
38    @Test("Task with special characters")
39    func itemWithSpecialCharacters() async throws {
40        let store = makeTestStore()
41        let specialTitle = "Test 🎉 with émojis & spëcial çharacters! @#$%^&*()"
42
43        let item = try store.createItem(title: specialTitle)
44
45        let items = try store.fetchItems()
46        #expect(items.first?.title == specialTitle)
47    }
48
49    @Test("Task with newlines and tabs")
50    func itemWithNewlinesAndTabs() async throws {
51        let store = makeTestStore()
52        let multilineTitle = "Line 1\nLine 2\tTabbed"
53
54        let item = try store.createItem(title: multilineTitle)
55
56        let items = try store.fetchItems()
57        #expect(items.first?.title == multilineTitle)
58    }
59
60    // MARK: - Large Data Sets
61
62    @Test("Create many items")
63    func createManyItems() async throws {
64        let store = makeTestStore()
65        let count = 100
66
67        for i in 0..<count {
68            _ = try store.createItem(title: "Task \(i)")
69        }
70
71        let items = try store.fetchItems()
72        #expect(items.count == count)
73    }
74
75    @Test("Delete all items from large set")
76    func deleteAllItemsFromLargeSet() async throws {
77        let store = makeTestStore()
78        var itemIDs: [UUID] = []
79
80        for i in 0..<50 {
81            let item = try store.createItem(title: "Task \(i)")
82            itemIDs.append(item.id)
83        }
84
85        for id in itemIDs {
86            try store.delete(itemID: id)
87        }
88
89        let items = try store.fetchItems()
90        #expect(items.isEmpty)
91    }
92
93    // MARK: - State Transitions
94
95    @Test("Create item after completing all items")
96    func createItemAfterCompletingAllItems() async throws {
97        let (store, itemIDs) = try makeTestStoreWithItems(count: 3)
98
99        for id in itemIDs {
100            try store.complete(itemID: id)
101        }
102
103        let newItem = try store.createItem(title: "New item")
104
105        let items = try store.fetchItems()
106        let activeItems = items.filter { !$0.isCompleted }
107        #expect(activeItems.count == 1)
108        #expect(activeItems[0].id == newItem.id)
109    }
110
111    @Test("Rapid updates to same item")
112    func rapidUpdatesToSameItem() async throws {
113        let store = makeTestStore()
114        let item = try store.createItem(title: "Original")
115
116        for i in 0..<10 {
117            try store.update(itemID: item.id, title: "Update \(i)")
118        }
119
120        let items = try store.fetchItems()
121        #expect(items.first?.title == "Update 9")
122    }
123
124    // MARK: - Store State Tests
125
126    @Test("Store with only completed items")
127    func storeWithOnlyCompletedItems() async throws {
128        let (store, itemIDs) = try makeTestStoreWithItems(count: 5)
129
130        for id in itemIDs {
131            try store.complete(itemID: id)
132        }
133
134        let items = try store.fetchItems()
135        #expect(items.allSatisfy { $0.isCompleted })
136        #expect(items.count == 5)
137    }
138
139    @Test("SortOrder after completing all items")
140    func sortOrderAfterCompletingAllItems() async throws {
141        let (store, itemIDs) = try makeTestStoreWithItems(count: 3)
142
143        for id in itemIDs {
144            try store.complete(itemID: id)
145        }
146
147        let newItem1 = try store.createItem(title: "New 1")
148        let newItem2 = try store.createItem(title: "New 2")
149
150        let activeItems = try store.fetchItems().filter { !$0.isCompleted }
151        #expect(activeItems[0].id == newItem1.id)
152        #expect(activeItems[1].id == newItem2.id)
153        #expect(activeItems[1].sortOrder > activeItems[0].sortOrder)
154    }
155
156    @Test("Uncompleting item moves it back to active")
157    func uncompletingItemMovesItBackToActive() async throws {
158        let (store, itemIDs) = try makeTestStoreWithItems(count: 3)
159        try store.complete(itemID: itemIDs[1])
160
161        try store.uncomplete(itemID: itemIDs[1])
162
163        let items = try store.fetchItems()
164        let activeItems = items.filter { !$0.isCompleted }
165        #expect(activeItems.count == 3)
166        #expect(activeItems.contains { $0.id == itemIDs[1] })
167    }
168
169    @Test("Uncomplete legacy sortOrder zero conflict appends to end")
170    func uncompleteLegacyZeroConflictAppendsToEnd() async throws {
171        let store = makeTestStore()
172        let activeItem = try store.createItem(title: "Active")
173        let completedItem = try store.createItem(title: "Completed")
174
175        activeItem.sortOrder = 0
176        try store.complete(itemID: completedItem.id)
177        completedItem.sortOrder = 0
178        try store.save()
179
180        try store.uncomplete(itemID: completedItem.id)
181
182        let activeItems = try store.fetchItems().filter { !$0.isCompleted }
183            .sorted { $0.sortOrder < $1.sortOrder }
184        #expect(activeItems.count == 2)
185        #expect(activeItems[0].id == activeItem.id)
186        #expect(activeItems[1].id == completedItem.id)
187        #expect(activeItems[1].sortOrder > activeItems[0].sortOrder)
188    }
189
190    @Test("Merge policy prefers store when store updatedAt is newer")
191    func mergePolicyPrefersStoreWhenStoreIsNewer() async throws {
192        let controller = PersistenceController(inMemory: true)
193        let viewContext = controller.viewContext
194        viewContext.automaticallyMergesChangesFromParent = false
195
196        let item = ItemEntity(context: viewContext)
197        item.title = "Original"
198        item.updatedAt = Date(timeIntervalSince1970: 100)
199        try viewContext.save()
200
201        let itemID = item.id
202
203        let backgroundContext = controller.container.newBackgroundContext()
204        try await backgroundContext.perform {
205            let request = ItemEntity.fetchRequest()
206            request.predicate = NSPredicate(format: "id == %@", itemID as CVarArg)
207            request.fetchLimit = 1
208
209            guard let remote = try backgroundContext.fetch(request).first else {
210                Issue.record("Expected item to exist in background context")
211                return
212            }
213
214            remote.title = "Remote newer"
215            remote.updatedAt = Date(timeIntervalSince1970: 200)
216            try backgroundContext.save()
217        }
218
219        item.updatedAt = Date(timeIntervalSince1970: 150)
220        try viewContext.save()
221
222        let verifyContext = controller.container.newBackgroundContext()
223        try await verifyContext.perform {
224            let request = ItemEntity.fetchRequest()
225            request.predicate = NSPredicate(format: "id == %@", itemID as CVarArg)
226            request.fetchLimit = 1
227
228            guard let resolved = try verifyContext.fetch(request).first else {
229                Issue.record("Expected item to exist in verify context")
230                return
231            }
232
233            #expect(resolved.title == "Remote newer")
234            #expect(resolved.updatedAt == Date(timeIntervalSince1970: 200))
235        }
236    }
237
238    @Test("Merge policy prefers local when local updatedAt is newer")
239    func mergePolicyPrefersLocalWhenLocalIsNewer() async throws {
240        let controller = PersistenceController(inMemory: true)
241        let viewContext = controller.viewContext
242        viewContext.automaticallyMergesChangesFromParent = false
243
244        let item = ItemEntity(context: viewContext)
245        item.title = "Original"
246        item.updatedAt = Date(timeIntervalSince1970: 100)
247        try viewContext.save()
248
249        let itemID = item.id
250
251        let backgroundContext = controller.container.newBackgroundContext()
252        try await backgroundContext.perform {
253            let request = ItemEntity.fetchRequest()
254            request.predicate = NSPredicate(format: "id == %@", itemID as CVarArg)
255            request.fetchLimit = 1
256
257            guard let remote = try backgroundContext.fetch(request).first else {
258                Issue.record("Expected item to exist in background context")
259                return
260            }
261
262            remote.title = "Remote older"
263            remote.updatedAt = Date(timeIntervalSince1970: 200)
264            try backgroundContext.save()
265        }
266
267        item.updatedAt = Date(timeIntervalSince1970: 300)
268        try viewContext.save()
269
270        let verifyContext = controller.container.newBackgroundContext()
271        try await verifyContext.perform {
272            let request = ItemEntity.fetchRequest()
273            request.predicate = NSPredicate(format: "id == %@", itemID as CVarArg)
274            request.fetchLimit = 1
275
276            guard let resolved = try verifyContext.fetch(request).first else {
277                Issue.record("Expected item to exist in verify context")
278                return
279            }
280
281            #expect(resolved.title == "Original")
282            #expect(resolved.updatedAt == Date(timeIntervalSince1970: 300))
283        }
284    }
285}