Skip to content

Commit 242f204

Browse files
committed
First implementation
1 parent f18f2d1 commit 242f204

File tree

2 files changed

+230
-9
lines changed

2 files changed

+230
-9
lines changed

Sources/TaskStore/TaskStore.swift

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,57 @@
1-
// The Swift Programming Language
2-
// https://docs.swift.org/swift-book
1+
import Foundation
2+
3+
/// A task store.
4+
@available(iOS 13.0, *)
5+
public class TaskStore<Key: Hashable> {
6+
7+
public typealias CancellableTask = Task<Void, Error>
8+
9+
var taskMap: [Key: CancellableTask] = [:]
10+
11+
private let lock: NSLock = NSLock()
12+
13+
public init() {}
14+
15+
/// Sets a `task` for `key` to the store.
16+
///
17+
/// If the task you set is completed, it automatically removed from the store.
18+
/// The work that remove completed tasks has the lowest priority.
19+
///
20+
/// - Parameters:
21+
/// - task: The task to add to the store.
22+
/// - key: The key to associate with task.
23+
public func setTask(_ task: CancellableTask, forKey key: Key) {
24+
lock.lock()
25+
taskMap.updateValue(task, forKey: key)
26+
lock.unlock()
27+
28+
Task(priority: .background) {
29+
_ = try? await task.value
30+
_ = lock.withLock {
31+
taskMap.removeValue(forKey: key)
32+
}
33+
}
34+
}
35+
36+
/// Returns the task identified by the given key.
37+
///
38+
/// - Parameter key: The key to associate with task.
39+
/// - Returns: The task identified by the given key. Otherwise, nil if the task for the given key doesn't exist or completed already.
40+
public func task(forKey key: Key) -> CancellableTask? {
41+
lock.lock(); defer { lock.unlock() }
42+
return taskMap[key]
43+
}
44+
45+
/// Returns the tasks in the store that satisfies the given predicate.
46+
/// - Parameter predicate: <#predicate description#>
47+
/// - Returns: <#description#>
48+
public func tasks(where predicate: (Key) -> Bool) -> [CancellableTask] {
49+
return taskMap
50+
.filter { key, _ in predicate(key) }
51+
.map(\.value)
52+
}
53+
54+
subscript(key: Key) -> CancellableTask? {
55+
return task(forKey: key)
56+
}
57+
}
Lines changed: 173 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,178 @@
1-
import XCTest
21
@testable import TaskStore
2+
import XCTest
33

44
final class TaskStoreTests: XCTestCase {
5-
func testExample() throws {
6-
// XCTest Documentation
7-
// https://developer.apple.com/documentation/xctest
8-
9-
// Defining Test Cases and Test Methods
10-
// https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods
5+
6+
func test_subscript() {
7+
let taskStore = TaskStore<Int>()
8+
taskStore.setTask(
9+
Task {
10+
usleep(100_000) // 100ms
11+
},
12+
forKey: 0
13+
)
14+
XCTAssertNotNil(taskStore[0])
15+
}
16+
17+
func test_searchTasks() {
18+
struct CustomKey: Hashable {
19+
let number: Int
20+
}
21+
22+
let taskStore = TaskStore<CustomKey>()
23+
24+
for number in 1...10 {
25+
let task = Task {
26+
sleep(1)
27+
try Task.checkCancellation()
28+
XCTAssert(true)
29+
}
30+
taskStore.setTask(task, forKey: CustomKey(number: number))
31+
}
32+
33+
XCTAssertEqual(taskStore.tasks(where: { $0.number < 4 }).count, 3)
34+
35+
sleep(2) // Wait enough for all works such as background task that remove above task or work that cancel above task in global dispatch queue.
36+
37+
XCTAssertEqual(taskStore.tasks(where: { _ in true }).count, 0)
38+
}
39+
40+
func test_multipleTasksProcessing() async throws {
41+
actor Count {
42+
var count = 0
43+
func increase() {
44+
count += 1
45+
}
46+
}
47+
48+
let taskStore = TaskStore<Int>()
49+
let taskCount = 10
50+
let successCount = Count()
51+
let cancelMask = [
52+
true,
53+
false, // Index: 1
54+
true,
55+
true,
56+
false, // 4
57+
false, // 5
58+
true,
59+
false, // 7
60+
true,
61+
true
62+
]
63+
64+
for order in 0..<taskCount {
65+
let task = Task {
66+
sleep(1)
67+
try Task.checkCancellation()
68+
await successCount.increase()
69+
}
70+
taskStore.setTask(task, forKey: order)
71+
}
72+
73+
let beforeCount = await successCount.count
74+
XCTAssertEqual(beforeCount, 0)
75+
XCTAssertEqual(taskStore.taskMap.count, taskCount)
76+
77+
cancelMask.enumerated()
78+
.filter { $1 }
79+
.forEach { index, _ in
80+
taskStore.task(forKey: index)?.cancel()
81+
}
82+
83+
sleep(3) // Wait enough for all works such as background task that remove above task or work that cancel above task in global dispatch queue.
84+
85+
let afterCount = await successCount.count
86+
XCTAssertEqual(afterCount, cancelMask.filter({ !$0 }).count) // `false` count
87+
XCTAssertEqual(taskStore.taskMap.count, 0)
88+
}
89+
90+
func test_successImageUpdate_withTask() async throws {
91+
let taskStore = TaskStore<String>()
92+
let imageView = await UIImageView()
93+
94+
let imageUpdatingTask = Task {
95+
sleep(1) // Networking...
96+
let imageName = "star"
97+
98+
// Check cancellation after done networking before do additional processes.
99+
try Task.checkCancellation()
100+
101+
sleep(1) // Do additional processes...
102+
let image = UIImage(systemName: imageName)!
103+
104+
// Check cancellation after done additional processes before actually update image.
105+
try Task.checkCancellation()
106+
107+
DispatchQueue.main.async {
108+
// Actual update
109+
imageView.image = image
110+
}
111+
}
112+
113+
taskStore.setTask(imageUpdatingTask, forKey: "image-updating") // Set task to `TaskStore`.
114+
115+
let whenToNeedCancel: DispatchTime = .now() + 2.5 // It time to already complete the image update.
116+
DispatchQueue.global().asyncAfter(deadline: whenToNeedCancel) {
117+
let task = taskStore.task(forKey: "image-updating")
118+
task?.cancel()
119+
}
120+
121+
XCTAssertNotNil(taskStore.task(forKey: "image-updating"))
122+
123+
_ = try await imageUpdatingTask.value // Check if complete the task.
124+
125+
DispatchQueue.main.async {
126+
XCTAssertEqual(imageView.image?.pngData(), UIImage(systemName: "star")?.pngData())
127+
}
128+
129+
sleep(3) // Wait enough for all works such as background task that remove above task or work that cancel above task in global dispatch queue.
130+
131+
XCTAssertNil(taskStore.task(forKey: "image-updating"))
132+
}
133+
134+
func test_failureImageUpdate_withTask() async throws {
135+
let taskStore = TaskStore<String>()
136+
let imageView = await UIImageView()
137+
138+
let imageUpdatingTask = Task {
139+
sleep(1) // Networking...
140+
let imageName = "star"
141+
142+
// Check cancellation after done networking before do additional processes.
143+
try Task.checkCancellation()
144+
145+
sleep(1) // Do additional processes...
146+
let image = UIImage(systemName: imageName)!
147+
148+
// Check cancellation after done additional processes before actually update image.
149+
try Task.checkCancellation()
150+
151+
DispatchQueue.main.async {
152+
// Actual update
153+
imageView.image = image
154+
XCTFail("This task did not stop, check the point that cancel the task.")
155+
}
156+
}
157+
158+
taskStore.setTask(imageUpdatingTask, forKey: "image-updating") // Set task to `TaskStore`.
159+
160+
let whenToNeedCancel: DispatchTime = .now() + 1.5 // Not enough time to complete the image update.
161+
DispatchQueue.global().asyncAfter(deadline: whenToNeedCancel) {
162+
let task = taskStore.task(forKey: "image-updating")
163+
task?.cancel()
164+
}
165+
166+
XCTAssertNotNil(taskStore.task(forKey: "image-updating"))
167+
168+
_ = try? await imageUpdatingTask.value // Check if complete the task.(Using `try?` so that test don't break.)
169+
170+
DispatchQueue.main.async {
171+
XCTAssertNil(imageView.image)
172+
}
173+
174+
sleep(3) // Wait enough for all works such as background task that remove above task or work that cancel above task in global dispatch queue.
175+
176+
XCTAssertNil(taskStore.task(forKey: "image-updating"))
11177
}
12178
}

0 commit comments

Comments
 (0)